This commit is contained in:
Kalle
2026-08-17 21:47:44 +03:00
parent 434f029a8b
commit b8dc31960a
7 changed files with 171 additions and 52 deletions

View File

@@ -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",

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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<PendingMessage[]>([]);
let nextPendingKey = 0;
let messagesContainer = $state<HTMLDivElement | null>(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(() => {
<section class={["container", className]}>
<div class="inputContainer">
<!-- xxx: the React app virtualizes this list (react-aria Virtualizer + ListLayout); no virtualizer here yet -->
<div
bind:this={messagesContainer}
class={["messages scrollbar", messagesContainerClass]}
@@ -128,6 +164,9 @@ $effect(() => {
aria-label="Chat messages"
onscroll={handleScroll}
>
{#if !snapshot}
<div class="streamStatus">{m.common_chat_connecting()}</div>
{/if}
{#each snapshot?.messages ?? [] as message (message.id)}
{@const systemText = systemMessageText(message)}
{#if systemText}
@@ -157,6 +196,11 @@ $effect(() => {
>
{user.username}
</div>
{#if user.pronouns}
<span class="pronounsTag">
{user.pronouns.subject}/{user.pronouns.object}
</span>
{/if}
<time class="messageTime">
{formatTimestamp(message.createdAt)}
</time>
@@ -168,12 +212,44 @@ $effect(() => {
</div>
{/if}
{/each}
{#if me}
{@const meAsChatUser = snapshot?.users[me.id]}
{#each visiblePendingMessages as pending (pending.key)}
<div class="message">
<div class="avatarWrapper">
<Avatar user={meAsChatUser ?? me} size="xs" />
</div>
<div>
<div class="messageInfo">
<div
class="messageUser"
style:--chat-hue={meAsChatUser?.chatNameHue ?? undefined}
>
{me.username}
</div>
{#if meAsChatUser?.pronouns}
<span class="pronounsTag">
{meAsChatUser.pronouns.subject}/{meAsChatUser.pronouns
.object}
</span>
{/if}
</div>
<div class="messageContents messageContentsPending">
{pending.contents}
</div>
</div>
</div>
{/each}
{/if}
</div>
{#if unseenBelowScroll}
<button type="button" class="unseenMessages" onclick={scrollToBottom}>
{m.common_chat_newMessages()}
</button>
{/if}
{#if snapshot && !room.connected}
<div class="streamStatus warning">{m.common_chat_disconnected()}</div>
{/if}
{#if snapshot && !canSend}
<div class="text-xs text-lighter text-center my-4">
{m.common_chat_expired()}
@@ -181,37 +257,23 @@ $effect(() => {
{:else}
<form onsubmit={handleSubmit}>
<input
class="w-full text-xs"
class="text-xs"
bind:value={inputValue}
placeholder={m.common_chat_input_placeholder()}
disabled={sendingDisabled}
maxlength={CHAT.MESSAGE_MAX_LENGTH}
/>
<div class="bottomRow">
{#if room.connected}
<div class="text-xxs font-semi-bold text-lighter">
{m.common_chat_connected()}
</div>
{:else if !room.done}
<div class="text-xxs font-semi-bold text-lighter">
{m.common_chat_connecting()}
</div>
{:else}
<div class="text-xxs font-semi-bold text-warning">
{m.common_chat_disconnected()}
</div>
{/if}
<Button
type="submit"
size="small"
class="sendButton"
disabled={sendingDisabled}
aria-label={m.common_chat_send()}
testId="chat-submit-button"
>
{#snippet icon()}<SendHorizontal size={16} />{/snippet}
</Button>
</div>
<Button
type="submit"
size="small"
shape="circle"
class="sendButton"
disabled={sendingDisabled}
aria-label={m.common_chat_send()}
testId="chat-submit-button"
>
{#snippet icon()}<SendHorizontal size={16} />{/snippet}
</Button>
</form>
{/if}
</div>
@@ -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 {

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import Main from "#lib/components/Main.svelte";
import MatchPage from "#lib/components/match-page/MatchPage.svelte";
import { chatUi } from "#lib/features/chat/chat-state.svelte.ts";
@@ -14,6 +15,7 @@ interface Props {
let { scrimPostId }: Props = $props();
// xxx: ???
// context must be provided before the first await of an async component; the
// getter closes over `data` which resolves before anything renders
setUserCardContext({
@@ -22,15 +24,20 @@ setUserCardContext({
const data = $derived(await getScrim({ scrimPostId }));
// the layout's chat sidebar auto-opens this scrim's room
// xxx: need a better, generic solution
// the layout's chat sidebar auto-opens this scrim's room; the chatUi writes
// are untracked so closing the room (openRoomId back to null) doesn't re-run
// this effect and force the room open again in an infinite loop
$effect(() => {
const chatRoomId = data.chatRoomId;
if (chatRoomId === undefined) return;
chatUi.routeChatRoomId = chatRoomId;
if (chatUi.openRoomId === null) {
chatUi.openRoomId = chatRoomId;
}
untrack(() => {
chatUi.routeChatRoomId = chatRoomId;
if (chatUi.openRoomId === null) {
chatUi.openRoomId = chatRoomId;
}
});
return () => {
chatUi.routeChatRoomId = null;

View File

@@ -12,6 +12,7 @@ import type {
} from "kysely";
import type {
PeakXP,
Pronouns,
UserPreferences,
XRankPlacementRegion,
} from "#lib/db/tables-json.ts";
@@ -84,6 +85,7 @@ export interface User {
languages: JSONColumnTypeNullable<UnifiedLanguageCode[]>;
patronTier: number | null;
patronStartedAt: number | null;
pronouns: JSONColumnTypeNullable<Pronouns>;
showDiscordUniqueName: Generated<DBBoolean>;
noScreen: Generated<DBBoolean>;
preferences: JSONColumnTypeNullable<UserPreferences>;