From a01353450e99ed030e51c19730d95e51ed9e0deb Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Mon, 6 Apr 2026 02:06:57 -0400 Subject: [PATCH] user statuses --- docker-compose.local.yml | 4 +- package.json | 2 +- spacetimedb/src/index.ts | 15 ++++- src/App.css | 6 +- src/chat/components/MemberList.svelte | 46 ++++++++++--- src/chat/components/MessageList.svelte | 53 +++++++-------- src/chat/components/ProfileModal.svelte | 67 +++++++++++++++++++ src/chat/components/SettingsPanel.svelte | 11 +++ src/chat/components/ThreadView.svelte | 23 +++++-- .../settings/AccountSettings.svelte | 18 +++++ src/chat/services/account.svelte.ts | 5 ++ src/chat/services/chat.svelte.ts | 4 ++ 12 files changed, 205 insertions(+), 49 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a4d0a17..da2bdab 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -8,7 +8,7 @@ services: environment: # Connect to the local SpacetimeDB instance - SPACETIMEDB_URI=ws://localhost:3000 - - SPACETIMEDB_DB_NAME=my-chat-app + - SPACETIMEDB_DB_NAME=zep depends_on: - module-publisher @@ -31,7 +31,7 @@ services: - spacetimedb environment: - SPACETIMEDB_URI=http://spacetimedb:3000 - - SPACETIMEDB_DB_NAME=my-chat-app + - SPACETIMEDB_DB_NAME=zep volumes: spacetimedb-data: diff --git a/package.json b/package.json index b028803..c806cd2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite --host=0.0.0.0", "dev:ssl": "VITE_USE_SSL=true vite --host=0.0.0.0", "build": "pnpm run spacetime:generate && tsc -b && vite build", - "lint": "eslint .", + "lint": "eslint . --fix", "format": "prettier . --write", "test": "vitest run", "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb", diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index 70ffbf7..abd29ec 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -20,6 +20,7 @@ const user = table( avatar_id: t.u64().optional(), banner_id: t.u64().optional(), biography: t.string().optional(), + status: t.string().optional(), }, ); @@ -687,11 +688,21 @@ export const set_biography = spacetimedb.reducer( { biography: t.string().optional() }, (ctx, { biography }) => { const user = ctx.db.user.identity.find(ctx.sender); - if (!user) throw new SenderError("Cannot set biography for unknown user"); + if (!user) throw new SenderError("User not found"); ctx.db.user.identity.update({ ...user, biography }); }, ); +export const set_status = spacetimedb.reducer( + { status: t.string().optional() }, + (ctx, { status }) => { + const user = ctx.db.user.identity.find(ctx.sender); + if (!user) throw new SenderError("User not found"); + ctx.db.user.identity.update({ ...user, status }); + }, +); + + export const set_talking = spacetimedb.reducer( { talking: t.bool(), channelId: t.u64() }, (ctx, { talking, channelId }) => { @@ -1349,6 +1360,7 @@ export const onConnect = spacetimedb.clientConnected((ctx) => { avatar_id: undefined, banner_id: undefined, biography: undefined, + status: undefined, }); autoJoinCommunityServer(ctx); } @@ -1366,6 +1378,7 @@ export const onConnect = spacetimedb.clientConnected((ctx) => { avatar_id: undefined, banner_id: undefined, biography: undefined, + status: undefined, }); autoJoinCommunityServer(ctx); } diff --git a/src/App.css b/src/App.css index 927a4a0..57aa71e 100644 --- a/src/App.css +++ b/src/App.css @@ -7,9 +7,9 @@ "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; --font-code: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; - --status-positive: var(--status-positive); - --status-danger: var(--status-danger); - --status-warning: var(--status-warning); + --status-positive: #23a559; + --status-danger: #f23f43; + --status-warning: #f0b232; } /* Premium Midnight Purple (Amethyst) */ diff --git a/src/chat/components/MemberList.svelte b/src/chat/components/MemberList.svelte index db717ba..5cd8a82 100644 --- a/src/chat/components/MemberList.svelte +++ b/src/chat/components/MemberList.svelte @@ -39,12 +39,17 @@
handleContextMenu(e, user)}> - - {user.name || user.identity.toHexString().substring(0, 8)} - {#if isMe(user)} - (You) +
+ + {user.name || user.identity.toHexString().substring(0, 8)} + {#if isMe(user)} + (You) + {/if} + + {#if user.status} +
{user.status}
{/if} - +
{#if isSharing(user)} @@ -62,12 +67,17 @@
handleContextMenu(e, user)}> - - {user.name || user.identity.toHexString().substring(0, 8)} - {#if isMe(user)} - (You) +
+ + {user.name || user.identity.toHexString().substring(0, 8)} + {#if isMe(user)} + (You) + {/if} + + {#if user.status} +
{user.status}
{/if} - +
@@ -82,4 +92,20 @@ opacity: 0.6; margin-left: 4px; } + + .member-details { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; + } + + .member-status { + font-size: 0.7rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1; + } diff --git a/src/chat/components/MessageList.svelte b/src/chat/components/MessageList.svelte index d875abe..515f006 100644 --- a/src/chat/components/MessageList.svelte +++ b/src/chat/components/MessageList.svelte @@ -8,6 +8,7 @@ let messagesEndRef = $state(null); let scrollContainer = $state(null); let lastChannelId = $state(null); + let lastMessageId = $state(null); let isAtBottom = $state(true); let oldScrollHeight = 0; let isPrepending = $state(false); @@ -63,22 +64,18 @@ const heightDiff = newHeight - lastHeight; if (heightDiff !== 0) { - const messagesList = chat.channelMessages; - const lastMsg = messagesList[messagesList.length - 1]; - const myIdHex = chat.identity?.toHexString(); - const lastMsgSenderHex = lastMsg?.sender.toHexString(); - const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; - - // If we ARE at the bottom, OR we just sent a message, keep us pinned to the NEW bottom - if (isAtBottom || sentByMe) { - scrollContainer!.scrollTop = target.scrollHeight; - } - // If the user is NOT at the bottom, and they aren't loading more history, - // we only adjust scroll if something above them expanded. - // For simplicity, we assume expansion happens at the point of interaction. - else if (!isPrepending) { - // No-op for expansion at/below viewport to let browser maintain focus on the item + // 1. If we are loading history (prepending), DO NOT snap to bottom. + // The $effect already handles the offset for prepending. + if (isPrepending) { + lastHeight = newHeight; + continue; } + + // 2. If we are AT THE BOTTOM, stay pinned. + if (isAtBottom) { + scrollContainer!.scrollTop = target.scrollHeight; + } + lastHeight = newHeight; } } @@ -89,14 +86,8 @@ }); function handleContentLoad() { - const messages = chat.channelMessages; - const lastMsg = messages[messages.length - 1]; - const myIdHex = chat.identity?.toHexString(); - const lastMsgSenderHex = lastMsg?.sender.toHexString(); - const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; - - // Only force scroll to bottom if we were already anchored or we just sent the message - if (isAtBottom || sentByMe) { + // Only force scroll to bottom if we were already anchored + if (isAtBottom) { scrollToBottom(false); } } @@ -118,20 +109,26 @@ if (messagesLocal.length > 0 && scrollContainer) { const isChannelSwitch = currentChannelId !== lastChannelId; const lastMsg = messagesLocal[messagesLocal.length - 1]; + const isNewLastMessage = lastMsg && lastMsg.id !== lastMessageId; + const myIdHex = chat.identity?.toHexString(); const lastMsgSenderHex = lastMsg?.sender.toHexString(); const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; if (isChannelSwitch) { lastChannelId = currentChannelId; + lastMessageId = lastMsg.id; isPrepending = false; scrollToBottom(false); lastHeight = scrollContainer.scrollHeight; - } else if (sentByMe) { - // Aggressive instant scroll for self-sent messages - scrollToBottom(false); - } else if (isAtBottom && !isPrepending) { - scrollToBottom(true); + } else if (isNewLastMessage) { + lastMessageId = lastMsg.id; + if (sentByMe) { + // Aggressive instant scroll for self-sent messages + scrollToBottom(false); + } else if (isAtBottom && !isPrepending) { + scrollToBottom(true); + } } } }); diff --git a/src/chat/components/ProfileModal.svelte b/src/chat/components/ProfileModal.svelte index c2dd206..c185724 100644 --- a/src/chat/components/ProfileModal.svelte +++ b/src/chat/components/ProfileModal.svelte @@ -9,6 +9,15 @@ const chat = getContext("chat"); const bannerUrl = $derived(user.bannerId ? chat.getBannerUrl(user) : null); + const isMe = $derived(chat.identity?.isEqual(user.identity)); + let isEditingStatus = $state(false); + let statusText = $state(user.status || ""); + + async function handleStatusSubmit(e: Event) { + e.preventDefault(); + await chat.handleSetStatus(statusText.trim() || undefined); + isEditingStatus = false; + } const handleOverlayClick = (e: MouseEvent) => { if (e.target === e.currentTarget) { @@ -41,6 +50,33 @@ @{user.identity.toHexString().substring(0, 8)}
+ {#if user.status || isMe} +
+ {#if isEditingStatus && isMe} +
+ { if (!statusText) isEditingStatus = false; }} + /> + +
+ {:else} + + +
isMe && (isEditingStatus = true)} + > + {user.status || (isMe ? "Click to set a status..." : "")} +
+ {/if} +
+ {/if} +

About Me

{user.biography || "No biography yet."}

@@ -134,6 +170,37 @@ gap: 20px; } + .status-section { + background-color: var(--background-secondary); + border-radius: 4px; + padding: 8px 12px; + border: 1px solid var(--background-modifier-accent); + } + + .status-text { + font-size: 0.9rem; + color: var(--text-normal); + word-break: break-word; + } + + .status-text.clickable { + cursor: pointer; + } + + .status-text.clickable:hover { + color: var(--header-primary); + } + + .status-form input { + width: 100%; + background: none; + border: none; + color: var(--text-normal); + font-size: 0.9rem; + outline: none; + padding: 0; + } + .name-section .display-name { margin: 0; font-size: 1.25rem; diff --git a/src/chat/components/SettingsPanel.svelte b/src/chat/components/SettingsPanel.svelte index d6d1522..bbe4a58 100644 --- a/src/chat/components/SettingsPanel.svelte +++ b/src/chat/components/SettingsPanel.svelte @@ -18,6 +18,7 @@ let activeCategory = $state("account"); let localName = $state(""); + let localStatus = $state(""); let biography = $state(""); let avatarPreview = $state(null); let newAvatarFile = $state(null); @@ -29,6 +30,9 @@ if (currentUser?.name) { localName = currentUser.name; } + if (currentUser?.status) { + localStatus = currentUser.status; + } if (currentUser?.biography) { biography = currentUser.biography; } @@ -49,6 +53,10 @@ chat.account.handleSetName(localName.trim()); } + if (localStatus.trim() !== (currentUser?.status || "")) { + chat.handleSetStatus(localStatus.trim() || undefined); + } + if (biography !== (currentUser?.biography || "")) { chat.handleSetBiography(biography.trim() || undefined); } @@ -107,6 +115,7 @@ const hasChanges = $derived( (localName.trim() !== (currentUser?.name || "") && localName.trim() !== "") || + localStatus.trim() !== (currentUser?.status || "") || biography !== (currentUser?.biography || "") || newAvatarFile !== null || (avatarPreview === null && currentUser?.avatarId) || @@ -150,6 +159,7 @@ {#if activeCategory === "account"}
+
+ +
+ + +
+
+