mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Fix chat
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user