From b8dc31960a95a0a8702ab929ebb67d27262187bc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:47:44 +0300 Subject: [PATCH] Fix chat --- apps/web/package.json | 2 +- apps/web/src/lib/db/tables-json.ts | 15 ++ .../features/chat/ChatRepository.server.ts | 2 +- apps/web/src/lib/features/chat/chat-types.ts | 3 + .../lib/features/chat/components/Chat.svelte | 182 +++++++++++++----- .../scrims/components/ScrimPage.svelte | 17 +- apps/web/src/lib/server/db/tables.ts | 2 + 7 files changed, 171 insertions(+), 52 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index bbc2c8eb0..fd4072fb7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "#lib/*": "./src/lib/*" }, "scripts": { - "dev": "pnpm run combine-locales && vite dev", + "dev": "pnpm run combine-locales && cross-env DB_PATH=../web-react/db.sqlite3 vite dev", "dev:prod": "pnpm run combine-locales && cross-env DB_PATH=../web-react/db-prod.sqlite3 VITE_PROD_MODE=true vite dev", "build": "pnpm run combine-locales && vite build", "build:all-locales": "tsx scripts/build-with-all-locales.ts", diff --git a/apps/web/src/lib/db/tables-json.ts b/apps/web/src/lib/db/tables-json.ts index 8b3fa9283..396be00a4 100644 --- a/apps/web/src/lib/db/tables-json.ts +++ b/apps/web/src/lib/db/tables-json.ts @@ -35,3 +35,18 @@ export interface PeakXP { /** Peak XP (Tentatek division) */ tentatek: number | null; } + +export type SubjectPronoun = "he" | "she" | "they" | "it" | "any"; + +export type ObjectPronoun = + | "him" + | "her" + | "them" + | "its" + | "all" + | SubjectPronoun; + +export type Pronouns = { + subject: SubjectPronoun; + object: ObjectPronoun; +}; diff --git a/apps/web/src/lib/features/chat/ChatRepository.server.ts b/apps/web/src/lib/features/chat/ChatRepository.server.ts index 8b19a8ea9..c807dd926 100644 --- a/apps/web/src/lib/features/chat/ChatRepository.server.ts +++ b/apps/web/src/lib/features/chat/ChatRepository.server.ts @@ -154,7 +154,7 @@ export async function findChatUsersByUserIds(userIds: number[]) { const users = await db .selectFrom("User") - .select((eb) => [...commonUserSelect(eb), userChatNameHue]) + .select((eb) => [...commonUserSelect(eb), "User.pronouns", userChatNameHue]) .where("User.id", "in", userIds) .execute(); diff --git a/apps/web/src/lib/features/chat/chat-types.ts b/apps/web/src/lib/features/chat/chat-types.ts index b10b4ba53..e54cf7afb 100644 --- a/apps/web/src/lib/features/chat/chat-types.ts +++ b/apps/web/src/lib/features/chat/chat-types.ts @@ -1,3 +1,5 @@ +import type { Pronouns } from "#lib/db/tables-json.ts"; + /** * System message types rendered inside a chat room. Grows as features that * post system messages migrate (sendouq & tournament add theirs later). @@ -26,6 +28,7 @@ export interface ChatUser { discordAvatar: string | null; customAvatarUrl?: string | null; chatNameHue?: number | null; + pronouns?: Pronouns | null; title?: string; } diff --git a/apps/web/src/lib/features/chat/components/Chat.svelte b/apps/web/src/lib/features/chat/components/Chat.svelte index b9908d218..992fc1547 100644 --- a/apps/web/src/lib/features/chat/components/Chat.svelte +++ b/apps/web/src/lib/features/chat/components/Chat.svelte @@ -2,6 +2,7 @@ import { SendHorizontal } from "@lucide/svelte"; import { Button } from "@sendou/components"; import Avatar from "#lib/components/Avatar.svelte"; +import { loggedInUser } from "#lib/features/auth/user-state.ts"; import { m } from "#lib/paraglide/messages.js"; import { getLocale } from "#lib/paraglide/runtime.js"; import { databaseTimestampToJavascriptTimestamp } from "#lib/utils/dates.ts"; @@ -15,19 +16,44 @@ interface Props { messagesContainerClass?: string; } +interface PendingMessage { + key: number; + contents: string; + /** Confirmed once the stream delivers an own message with a bigger id. */ + afterMessageId: number; +} + let { chatRoomId, class: className, messagesContainerClass }: Props = $props(); const room = $derived(getChatRoom({ chatRoomId })); const snapshot = $derived(room.current); +const me = $derived(loggedInUser()); let inputValue = $state(""); -let sendPending = $state(false); +let pendingMessages = $state([]); +let nextPendingKey = 0; let messagesContainer = $state(null); let stickToBottom = $state(true); let unseenBelowScroll = $state(false); const canSend = $derived(snapshot ? snapshot.lifecycle !== "ARCHIVED" : false); -const sendingDisabled = $derived(!canSend || !room.connected || sendPending); +// sending is a plain HTTP command, so a dropped live stream doesn't block it; +// only a room we haven't loaded yet (or an archived one) does +const sendingDisabled = $derived(!canSend); +const lastMessageId = $derived(snapshot?.messages.at(-1)?.id ?? null); + +// optimistically shown messages that the live stream hasn't confirmed yet +const visiblePendingMessages = $derived( + pendingMessages.filter( + (pending) => + !snapshot?.messages.some( + (message) => + message.id > pending.afterMessageId && + message.userId === me?.id && + message.contents === pending.contents, + ), + ), +); function systemMessageText(message: { type?: ChatSystemMessageType | null; @@ -49,17 +75,27 @@ async function handleSubmit(event: SubmitEvent) { event.preventDefault(); const contents = inputValue.trim(); - if (contents.length === 0) return; + if (contents.length === 0 || sendingDisabled) return; + + const pending: PendingMessage = { + key: ++nextPendingKey, + contents, + afterMessageId: lastMessageId ?? 0, + }; + // confirmed leftovers from earlier sends get pruned while appending + pendingMessages = [...visiblePendingMessages, pending]; + inputValue = ""; + stickToBottom = true; + requestAnimationFrame(scrollToBottom); - sendPending = true; try { await sendChatMessage({ chatRoomId, contents }); - inputValue = ""; - stickToBottom = true; } catch { - // keep the unsent text in the input so the user can retry - } finally { - sendPending = false; + // put the unsent text back in the input so the user can retry + pendingMessages = pendingMessages.filter( + (other) => other.key !== pending.key, + ); + inputValue = contents; } } @@ -94,7 +130,6 @@ function scrollToBottom() { unseenBelowScroll = false; } -const lastMessageId = $derived(snapshot?.messages.at(-1)?.id ?? null); let lastMarkedRead: number | null = null; // new messages: follow the conversation when pinned to the bottom, otherwise @@ -121,6 +156,7 @@ $effect(() => {
+
{ aria-label="Chat messages" onscroll={handleScroll} > + {#if !snapshot} +
{m.common_chat_connecting()}
+ {/if} {#each snapshot?.messages ?? [] as message (message.id)} {@const systemText = systemMessageText(message)} {#if systemText} @@ -157,6 +196,11 @@ $effect(() => { > {user.username}
+ {#if user.pronouns} + + {user.pronouns.subject}/{user.pronouns.object} + + {/if} @@ -168,12 +212,44 @@ $effect(() => {
{/if} {/each} + {#if me} + {@const meAsChatUser = snapshot?.users[me.id]} + {#each visiblePendingMessages as pending (pending.key)} +
+
+ +
+
+
+
+ {me.username} +
+ {#if meAsChatUser?.pronouns} + + {meAsChatUser.pronouns.subject}/{meAsChatUser.pronouns + .object} + + {/if} +
+
+ {pending.contents} +
+
+
+ {/each} + {/if} {#if unseenBelowScroll} {/if} + {#if snapshot && !room.connected} +
{m.common_chat_disconnected()}
+ {/if} {#if snapshot && !canSend}
{m.common_chat_expired()} @@ -181,37 +257,23 @@ $effect(() => { {:else}
-
- {#if room.connected} -
- {m.common_chat_connected()} -
- {:else if !room.done} -
- {m.common_chat_connecting()} -
- {:else} -
- {m.common_chat_disconnected()} -
- {/if} - -
+
{/if}
@@ -224,8 +286,10 @@ $effect(() => { } .messages { - padding: var(--s-3) 0 0 0; - display: block; + padding: var(--s-3) var(--s-2) 0; + display: flex; + flex-direction: column; + gap: var(--s-2); height: 310px; overflow-x: hidden; overflow-y: auto; @@ -235,6 +299,7 @@ $effect(() => { list-style: none; display: flex; gap: var(--s-3); + flex-shrink: 0; } .messageInfo { @@ -265,19 +330,49 @@ $effect(() => { color: var(--color-text-high); } + .streamStatus { + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + text-align: center; + padding: var(--s-2); + + &.warning { + color: var(--color-warning); + padding: 0 var(--s-2); + } + } + + .pronounsTag { + background-color: var(--color-bg-higher); + color: var(--color-text-accent); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + padding: 1px 5px; + border-radius: var(--radius-full); + white-space: nowrap; + } + .inputContainer { margin-top: auto; position: relative; & > form { - padding: var(--s-2); + display: flex; + align-items: center; + gap: var(--s-1-5); + padding: var(--s-1-5); border-top: 1.5px solid var(--color-border); margin-block-start: var(--s-4); + + & > input { + flex: 1; + min-width: 0; + } } } .inputContainer :global(.sendButton) { - border-radius: var(--radius-full); background-color: var(--color-text-accent); color: var(--color-text-inverse); flex-shrink: 0; @@ -292,11 +387,8 @@ $effect(() => { word-break: break-word; } - .bottomRow { - display: flex; - justify-content: space-between; - align-items: center; - margin-block-start: var(--s-2); + .messageContentsPending { + opacity: 0.7; } .unseenMessages { diff --git a/apps/web/src/lib/features/scrims/components/ScrimPage.svelte b/apps/web/src/lib/features/scrims/components/ScrimPage.svelte index fd1b121ea..f540b5f12 100644 --- a/apps/web/src/lib/features/scrims/components/ScrimPage.svelte +++ b/apps/web/src/lib/features/scrims/components/ScrimPage.svelte @@ -1,4 +1,5 @@