Compare commits
6 Commits
48b06f4594
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| deaa7a29c5 | |||
| 91de26d80b | |||
| 6b6362a629 | |||
| 8a06fbc3c7 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -207,10 +207,11 @@ type provisionSeed struct {
|
|||||||
|
|
||||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||||
// editable format). When neither yields any letters, it falls back to a generated
|
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||||
// placeholder in the seeded language (placeholderDisplayName).
|
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
|
||||||
|
// only when firstName has no usable letters and no username is set.
|
||||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
@@ -218,7 +219,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
|||||||
}
|
}
|
||||||
name := sanitizeDisplayName(firstName)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = sanitizeDisplayName(username)
|
// The real name yielded nothing usable: fall back to the @username verbatim
|
||||||
|
// (Telegram guarantees a valid handle), only trimmed and capped to the column
|
||||||
|
// width — never character-stripped like the real name.
|
||||||
|
name = strings.TrimSpace(username)
|
||||||
|
if r := []rune(name); len(r) > maxDisplayName {
|
||||||
|
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = placeholderDisplayName(seed.preferredLanguage)
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
|
|
||||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||||
// the first-name / username display-name precedence, and the sanitization that
|
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||||
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
// the sanitization of the real name (emoji, digits, punctuation stripped to the
|
||||||
|
// editable format). The username, when used, is kept verbatim.
|
||||||
func TestTelegramSeed(t *testing.T) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||||
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||||
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||||
|
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
|
|||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
wantRe string
|
wantRe string
|
||||||
}{
|
}{
|
||||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||||
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||||
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
|
"name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export const en = {
|
|||||||
'invitations.with': 'With {names}',
|
'invitations.with': 'With {names}',
|
||||||
'invitations.accept': 'Accept',
|
'invitations.accept': 'Accept',
|
||||||
'invitations.decline': 'Decline',
|
'invitations.decline': 'Decline',
|
||||||
|
'invitations.declineConfirm': 'Decline invitation?',
|
||||||
'invitations.cancel': 'Cancel',
|
'invitations.cancel': 'Cancel',
|
||||||
'invitations.waiting': 'Waiting for replies',
|
'invitations.waiting': 'Waiting for replies',
|
||||||
|
|
||||||
@@ -264,10 +265,10 @@ export const en = {
|
|||||||
'new.withFriends': 'Play with friends',
|
'new.withFriends': 'Play with friends',
|
||||||
'new.pickFriends': 'Choose who to invite',
|
'new.pickFriends': 'Choose who to invite',
|
||||||
'new.searchFriends': 'Search friends',
|
'new.searchFriends': 'Search friends',
|
||||||
'new.gameType': 'Game type',
|
'new.gameType': 'Variant',
|
||||||
'new.invite': 'Send invitation',
|
'new.invite': 'Send invitation',
|
||||||
'new.moveTime': 'Move time',
|
'new.moveTime': 'Move time',
|
||||||
'new.hintsPerPlayer': 'Hints per player',
|
'new.hintsPerPlayer': 'Hints',
|
||||||
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
||||||
'new.start': 'Start game',
|
'new.start': 'Start game',
|
||||||
'new.invited': 'Invitation sent.',
|
'new.invited': 'Invitation sent.',
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'invitations.with': 'С {names}',
|
'invitations.with': 'С {names}',
|
||||||
'invitations.accept': 'Принять',
|
'invitations.accept': 'Принять',
|
||||||
'invitations.decline': 'Отклонить',
|
'invitations.decline': 'Отклонить',
|
||||||
|
'invitations.declineConfirm': 'Отклонить приглашение?',
|
||||||
'invitations.cancel': 'Отменить',
|
'invitations.cancel': 'Отменить',
|
||||||
'invitations.waiting': 'Ожидаем ответы',
|
'invitations.waiting': 'Ожидаем ответы',
|
||||||
|
|
||||||
@@ -265,10 +266,10 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.withFriends': 'Игра с друзьями',
|
'new.withFriends': 'Игра с друзьями',
|
||||||
'new.pickFriends': 'Кого пригласить',
|
'new.pickFriends': 'Кого пригласить',
|
||||||
'new.searchFriends': 'Поиск друзей',
|
'new.searchFriends': 'Поиск друзей',
|
||||||
'new.gameType': 'Тип игры',
|
'new.gameType': 'Вариант',
|
||||||
'new.invite': 'Отправить приглашение',
|
'new.invite': 'Отправить приглашение',
|
||||||
'new.moveTime': 'Время на ход',
|
'new.moveTime': 'Время на ход',
|
||||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
'new.hintsPerPlayer': 'Подсказки',
|
||||||
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||||
'new.start': 'Начать игру',
|
'new.start': 'Начать игру',
|
||||||
'new.invited': 'Приглашение отправлено.',
|
'new.invited': 'Приглашение отправлено.',
|
||||||
|
|||||||
+83
-20
@@ -3,17 +3,19 @@
|
|||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
|
import Modal from '../components/Modal.svelte';
|
||||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { resultBadge } from '../lib/result';
|
import { resultBadge } from '../lib/result';
|
||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||||
import { preloadGames } from '../lib/preload';
|
import { preloadGames } from '../lib/preload';
|
||||||
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
||||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||||
|
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
|
||||||
|
|
||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
let invitations = $state<Invitation[]>([]);
|
let invitations = $state<Invitation[]>([]);
|
||||||
@@ -209,11 +211,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantKey: Record<string, MessageKey> = {
|
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
|
||||||
scrabble_en: 'new.english',
|
// in-game resign confirmation) rather than declining on the first tap.
|
||||||
scrabble_ru: 'new.russian',
|
let declineTarget = $state<Invitation | null>(null);
|
||||||
erudit_ru: 'new.erudit',
|
function confirmDecline() {
|
||||||
};
|
const inv = declineTarget;
|
||||||
|
declineTarget = null;
|
||||||
|
if (inv) declineInvite(inv);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||||
@@ -230,16 +235,23 @@
|
|||||||
<span class="sub">{t('invitations.waiting')}</span>
|
<span class="sub">{t('invitations.waiting')}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
|
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
|
||||||
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
|
<span class="vrow">
|
||||||
|
{#if VARIANT_FLAG[inv.variant]}
|
||||||
|
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
|
||||||
|
{:else}
|
||||||
|
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||||
|
{/if}
|
||||||
|
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
|
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
{#if inv.inviter.accountId === myId}
|
{#if inv.inviter.accountId === myId}
|
||||||
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
|
<button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={t('invitations.cancel')}>❌</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
|
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}>✅</button>
|
||||||
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
|
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}>❌</button>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,6 +320,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if declineTarget}
|
||||||
|
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
|
||||||
|
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
@@ -365,6 +386,40 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
/* The middle column grows; the envelope and the action column stay at their natural width. */
|
||||||
|
.invite .info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
|
||||||
|
.vrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.vflag {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.vflag-img {
|
||||||
|
width: 1.3rem;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 2px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
|
||||||
|
.iconbtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.iconbtn:active {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||||||
consecutive rows. */
|
consecutive rows. */
|
||||||
.list {
|
.list {
|
||||||
@@ -516,23 +571,31 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
|
||||||
.acts {
|
.acts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.btn {
|
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
|
||||||
padding: 8px 12px;
|
.confirm-row {
|
||||||
border: 1px solid var(--accent);
|
display: flex;
|
||||||
background: var(--accent);
|
gap: 8px;
|
||||||
color: var(--accent-text);
|
}
|
||||||
|
.confirm-row button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 11px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
|
||||||
.ghost {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius-sm);
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.danger {
|
||||||
|
background: var(--danger) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,9 +67,12 @@
|
|||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
let selected = $state<string[]>([]);
|
let selected = $state<string[]>([]);
|
||||||
let friendFilter = $state('');
|
let friendFilter = $state('');
|
||||||
// No default game type yet — the player must pick one (a smarter default from play
|
// A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
|
||||||
// history / language would be a future refinement). '' renders the disabled placeholder option.
|
// with several, the player picks. '' renders the disabled placeholder option.
|
||||||
let inviteVariant = $state<Variant | ''>('');
|
let inviteVariant = $state<Variant | ''>('');
|
||||||
|
$effect(() => {
|
||||||
|
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
|
||||||
|
});
|
||||||
let timeoutSecs = $state(86400);
|
let timeoutSecs = $state(86400);
|
||||||
let hints = $state(1);
|
let hints = $state(1);
|
||||||
|
|
||||||
@@ -152,6 +155,7 @@
|
|||||||
<input type="checkbox" bind:checked={multipleWords} />
|
<input type="checkbox" bind:checked={multipleWords} />
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="grow"></div>
|
||||||
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
||||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||||
<button
|
<button
|
||||||
@@ -179,7 +183,7 @@
|
|||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{t('new.gameType')}</span>
|
<span>{t('new.gameType')}</span>
|
||||||
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}>
|
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
|
||||||
<option value="" disabled>—</option>
|
<option value="" disabled>—</option>
|
||||||
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -387,6 +391,11 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
|
||||||
|
mirroring the friend-game invite button pinned by the .fg scroll area. */
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
.invite {
|
.invite {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user