user statuses

This commit is contained in:
2026-04-06 02:06:57 -04:00
parent 3400e51d21
commit a01353450e
12 changed files with 205 additions and 49 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ services:
environment: environment:
# Connect to the local SpacetimeDB instance # Connect to the local SpacetimeDB instance
- SPACETIMEDB_URI=ws://localhost:3000 - SPACETIMEDB_URI=ws://localhost:3000
- SPACETIMEDB_DB_NAME=my-chat-app - SPACETIMEDB_DB_NAME=zep
depends_on: depends_on:
- module-publisher - module-publisher
@@ -31,7 +31,7 @@ services:
- spacetimedb - spacetimedb
environment: environment:
- SPACETIMEDB_URI=http://spacetimedb:3000 - SPACETIMEDB_URI=http://spacetimedb:3000
- SPACETIMEDB_DB_NAME=my-chat-app - SPACETIMEDB_DB_NAME=zep
volumes: volumes:
spacetimedb-data: spacetimedb-data:
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite --host=0.0.0.0", "dev": "vite --host=0.0.0.0",
"dev:ssl": "VITE_USE_SSL=true 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", "build": "pnpm run spacetime:generate && tsc -b && vite build",
"lint": "eslint .", "lint": "eslint . --fix",
"format": "prettier . --write", "format": "prettier . --write",
"test": "vitest run", "test": "vitest run",
"spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb", "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb",
+14 -1
View File
@@ -20,6 +20,7 @@ const user = table(
avatar_id: t.u64().optional(), avatar_id: t.u64().optional(),
banner_id: t.u64().optional(), banner_id: t.u64().optional(),
biography: t.string().optional(), biography: t.string().optional(),
status: t.string().optional(),
}, },
); );
@@ -687,11 +688,21 @@ export const set_biography = spacetimedb.reducer(
{ biography: t.string().optional() }, { biography: t.string().optional() },
(ctx, { biography }) => { (ctx, { biography }) => {
const user = ctx.db.user.identity.find(ctx.sender); 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 }); 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( export const set_talking = spacetimedb.reducer(
{ talking: t.bool(), channelId: t.u64() }, { talking: t.bool(), channelId: t.u64() },
(ctx, { talking, channelId }) => { (ctx, { talking, channelId }) => {
@@ -1349,6 +1360,7 @@ export const onConnect = spacetimedb.clientConnected((ctx) => {
avatar_id: undefined, avatar_id: undefined,
banner_id: undefined, banner_id: undefined,
biography: undefined, biography: undefined,
status: undefined,
}); });
autoJoinCommunityServer(ctx); autoJoinCommunityServer(ctx);
} }
@@ -1366,6 +1378,7 @@ export const onConnect = spacetimedb.clientConnected((ctx) => {
avatar_id: undefined, avatar_id: undefined,
banner_id: undefined, banner_id: undefined,
biography: undefined, biography: undefined,
status: undefined,
}); });
autoJoinCommunityServer(ctx); autoJoinCommunityServer(ctx);
} }
+3 -3
View File
@@ -7,9 +7,9 @@
"gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-code: --font-code:
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
--status-positive: var(--status-positive); --status-positive: #23a559;
--status-danger: var(--status-danger); --status-danger: #f23f43;
--status-warning: var(--status-warning); --status-warning: #f0b232;
} }
/* Premium Midnight Purple (Amethyst) */ /* Premium Midnight Purple (Amethyst) */
+26
View File
@@ -39,12 +39,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, user)}> <div class="member-item" oncontextmenu={(e) => handleContextMenu(e, user)}>
<Avatar user={user} size="tiny" isTalking={isTalking(user)} /> <Avatar user={user} size="tiny" isTalking={isTalking(user)} />
<div class="member-details">
<span class="member-name {isTalking(user) ? 'talking' : ''}"> <span class="member-name {isTalking(user) ? 'talking' : ''}">
{user.name || user.identity.toHexString().substring(0, 8)} {user.name || user.identity.toHexString().substring(0, 8)}
{#if isMe(user)} {#if isMe(user)}
<span class="me-badge">(You)</span> <span class="me-badge">(You)</span>
{/if} {/if}
</span> </span>
{#if user.status}
<div class="member-status" title={user.status}>{user.status}</div>
{/if}
</div>
<div style="flex: 1;"></div> <div style="flex: 1;"></div>
{#if isSharing(user)} {#if isSharing(user)}
<span class="sharing-badge">LIVE</span> <span class="sharing-badge">LIVE</span>
@@ -62,12 +67,17 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, user)}> <div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, user)}>
<Avatar user={user} size="tiny" /> <Avatar user={user} size="tiny" />
<div class="member-details">
<span class="member-name"> <span class="member-name">
{user.name || user.identity.toHexString().substring(0, 8)} {user.name || user.identity.toHexString().substring(0, 8)}
{#if isMe(user)} {#if isMe(user)}
<span class="me-badge">(You)</span> <span class="me-badge">(You)</span>
{/if} {/if}
</span> </span>
{#if user.status}
<div class="member-status" title={user.status}>{user.status}</div>
{/if}
</div>
<div style="flex: 1;"></div> <div style="flex: 1;"></div>
<div class="status-dot grey"></div> <div class="status-dot grey"></div>
</div> </div>
@@ -82,4 +92,20 @@
opacity: 0.6; opacity: 0.6;
margin-left: 4px; 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;
}
</style> </style>
+19 -22
View File
@@ -8,6 +8,7 @@
let messagesEndRef = $state<HTMLDivElement | null>(null); let messagesEndRef = $state<HTMLDivElement | null>(null);
let scrollContainer = $state<HTMLDivElement | null>(null); let scrollContainer = $state<HTMLDivElement | null>(null);
let lastChannelId = $state<bigint | null>(null); let lastChannelId = $state<bigint | null>(null);
let lastMessageId = $state<bigint | null>(null);
let isAtBottom = $state(true); let isAtBottom = $state(true);
let oldScrollHeight = 0; let oldScrollHeight = 0;
let isPrepending = $state(false); let isPrepending = $state(false);
@@ -63,22 +64,18 @@
const heightDiff = newHeight - lastHeight; const heightDiff = newHeight - lastHeight;
if (heightDiff !== 0) { if (heightDiff !== 0) {
const messagesList = chat.channelMessages; // 1. If we are loading history (prepending), DO NOT snap to bottom.
const lastMsg = messagesList[messagesList.length - 1]; // The $effect already handles the offset for prepending.
const myIdHex = chat.identity?.toHexString(); if (isPrepending) {
const lastMsgSenderHex = lastMsg?.sender.toHexString(); lastHeight = newHeight;
const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; continue;
}
// If we ARE at the bottom, OR we just sent a message, keep us pinned to the NEW bottom // 2. If we are AT THE BOTTOM, stay pinned.
if (isAtBottom || sentByMe) { if (isAtBottom) {
scrollContainer!.scrollTop = target.scrollHeight; 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
}
lastHeight = newHeight; lastHeight = newHeight;
} }
} }
@@ -89,14 +86,8 @@
}); });
function handleContentLoad() { function handleContentLoad() {
const messages = chat.channelMessages; // Only force scroll to bottom if we were already anchored
const lastMsg = messages[messages.length - 1]; if (isAtBottom) {
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) {
scrollToBottom(false); scrollToBottom(false);
} }
} }
@@ -118,22 +109,28 @@
if (messagesLocal.length > 0 && scrollContainer) { if (messagesLocal.length > 0 && scrollContainer) {
const isChannelSwitch = currentChannelId !== lastChannelId; const isChannelSwitch = currentChannelId !== lastChannelId;
const lastMsg = messagesLocal[messagesLocal.length - 1]; const lastMsg = messagesLocal[messagesLocal.length - 1];
const isNewLastMessage = lastMsg && lastMsg.id !== lastMessageId;
const myIdHex = chat.identity?.toHexString(); const myIdHex = chat.identity?.toHexString();
const lastMsgSenderHex = lastMsg?.sender.toHexString(); const lastMsgSenderHex = lastMsg?.sender.toHexString();
const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex;
if (isChannelSwitch) { if (isChannelSwitch) {
lastChannelId = currentChannelId; lastChannelId = currentChannelId;
lastMessageId = lastMsg.id;
isPrepending = false; isPrepending = false;
scrollToBottom(false); scrollToBottom(false);
lastHeight = scrollContainer.scrollHeight; lastHeight = scrollContainer.scrollHeight;
} else if (sentByMe) { } else if (isNewLastMessage) {
lastMessageId = lastMsg.id;
if (sentByMe) {
// Aggressive instant scroll for self-sent messages // Aggressive instant scroll for self-sent messages
scrollToBottom(false); scrollToBottom(false);
} else if (isAtBottom && !isPrepending) { } else if (isAtBottom && !isPrepending) {
scrollToBottom(true); scrollToBottom(true);
} }
} }
}
}); });
const messages = $derived(chat.channelMessages); const messages = $derived(chat.channelMessages);
+67
View File
@@ -9,6 +9,15 @@
const chat = getContext<ChatService>("chat"); const chat = getContext<ChatService>("chat");
const bannerUrl = $derived(user.bannerId ? chat.getBannerUrl(user) : null); 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) => { const handleOverlayClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
@@ -41,6 +50,33 @@
<span class="username">@{user.identity.toHexString().substring(0, 8)}</span> <span class="username">@{user.identity.toHexString().substring(0, 8)}</span>
</div> </div>
{#if user.status || isMe}
<div class="status-section">
{#if isEditingStatus && isMe}
<form onsubmit={handleStatusSubmit} class="status-form">
<input
type="text"
bind:value={statusText}
placeholder="Set a status..."
maxlength="128"
autofocus
onblur={() => { if (!statusText) isEditingStatus = false; }}
/>
<button type="submit" style="display: none;"></button>
</form>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="status-text {isMe ? 'clickable' : ''}"
onclick={() => isMe && (isEditingStatus = true)}
>
{user.status || (isMe ? "Click to set a status..." : "")}
</div>
{/if}
</div>
{/if}
<div class="biography-section"> <div class="biography-section">
<h3>About Me</h3> <h3>About Me</h3>
<p class="biography">{user.biography || "No biography yet."}</p> <p class="biography">{user.biography || "No biography yet."}</p>
@@ -134,6 +170,37 @@
gap: 20px; 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 { .name-section .display-name {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
+11
View File
@@ -18,6 +18,7 @@
let activeCategory = $state("account"); let activeCategory = $state("account");
let localName = $state(""); let localName = $state("");
let localStatus = $state("");
let biography = $state(""); let biography = $state("");
let avatarPreview = $state<string | null>(null); let avatarPreview = $state<string | null>(null);
let newAvatarFile = $state<File | null>(null); let newAvatarFile = $state<File | null>(null);
@@ -29,6 +30,9 @@
if (currentUser?.name) { if (currentUser?.name) {
localName = currentUser.name; localName = currentUser.name;
} }
if (currentUser?.status) {
localStatus = currentUser.status;
}
if (currentUser?.biography) { if (currentUser?.biography) {
biography = currentUser.biography; biography = currentUser.biography;
} }
@@ -49,6 +53,10 @@
chat.account.handleSetName(localName.trim()); chat.account.handleSetName(localName.trim());
} }
if (localStatus.trim() !== (currentUser?.status || "")) {
chat.handleSetStatus(localStatus.trim() || undefined);
}
if (biography !== (currentUser?.biography || "")) { if (biography !== (currentUser?.biography || "")) {
chat.handleSetBiography(biography.trim() || undefined); chat.handleSetBiography(biography.trim() || undefined);
} }
@@ -107,6 +115,7 @@
const hasChanges = $derived( const hasChanges = $derived(
(localName.trim() !== (currentUser?.name || "") && localName.trim() !== "") || (localName.trim() !== (currentUser?.name || "") && localName.trim() !== "") ||
localStatus.trim() !== (currentUser?.status || "") ||
biography !== (currentUser?.biography || "") || biography !== (currentUser?.biography || "") ||
newAvatarFile !== null || newAvatarFile !== null ||
(avatarPreview === null && currentUser?.avatarId) || (avatarPreview === null && currentUser?.avatarId) ||
@@ -150,6 +159,7 @@
{#if activeCategory === "account"} {#if activeCategory === "account"}
<AccountSettings <AccountSettings
bind:localName bind:localName
bind:localStatus
bind:biography bind:biography
bind:avatarPreview bind:avatarPreview
bind:newAvatarFile bind:newAvatarFile
@@ -180,6 +190,7 @@
<div class="footer-actions"> <div class="footer-actions">
<button class="btn-ghost" onclick={() => { <button class="btn-ghost" onclick={() => {
localName = currentUser?.name || ""; localName = currentUser?.name || "";
localStatus = currentUser?.status || "";
biography = currentUser?.biography || ""; biography = currentUser?.biography || "";
avatarPreview = chat.getAvatarUrl(currentUser); avatarPreview = chat.getAvatarUrl(currentUser);
bannerPreview = chat.getBannerUrl(currentUser); bannerPreview = chat.getBannerUrl(currentUser);
+19 -4
View File
@@ -40,6 +40,7 @@
let scrollContainer = $state<HTMLDivElement | null>(null); let scrollContainer = $state<HTMLDivElement | null>(null);
let isAtBottom = $state(true); let isAtBottom = $state(true);
let lastThreadId = $state<bigint | null>(null); let lastThreadId = $state<bigint | null>(null);
let lastMessageId = $state<bigint | null>(null);
function handleScroll() { function handleScroll() {
if (!scrollContainer) return; if (!scrollContainer) return;
@@ -89,16 +90,30 @@
} }
$effect(() => { $effect(() => {
// Track threadMessages and activeThreadId
const messages = threadMessages; const messages = threadMessages;
const currentThreadId = activeThreadId; const currentThreadId = activeThreadId;
if (messages.length > 0 || parentMessage) { if (messages.length > 0 || parentMessage) {
const isThreadSwitch = currentThreadId !== lastThreadId; const isThreadSwitch = currentThreadId !== lastThreadId;
lastThreadId = currentThreadId; const lastMsg = messages[messages.length - 1];
const isNewLastMessage = lastMsg && lastMsg.id !== lastMessageId;
if (isThreadSwitch || isAtBottom) { if (isThreadSwitch) {
scrollToBottom(!isThreadSwitch); lastThreadId = currentThreadId;
lastMessageId = lastMsg?.id || null;
scrollToBottom(false);
} else if (isNewLastMessage) {
lastMessageId = lastMsg.id;
const myIdHex = chat.identity?.toHexString();
const lastMsgSenderHex = lastMsg?.sender.toHexString();
const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex;
if (sentByMe) {
scrollToBottom(false);
} else if (isAtBottom) {
scrollToBottom(true);
}
} }
} }
}); });
@@ -5,6 +5,7 @@
let { let {
localName = $bindable(), localName = $bindable(),
localStatus = $bindable(),
biography = $bindable(), biography = $bindable(),
avatarPreview = $bindable(), avatarPreview = $bindable(),
newAvatarFile = $bindable(), newAvatarFile = $bindable(),
@@ -14,6 +15,7 @@
currentUser, currentUser,
}: { }: {
localName: string; localName: string;
localStatus: string;
biography: string; biography: string;
avatarPreview: string | null; avatarPreview: string | null;
newAvatarFile: File | null; newAvatarFile: File | null;
@@ -99,6 +101,22 @@
</div> </div>
</div> </div>
<div class="form-group">
<label for="user-status">Status</label>
<div class="input-wrapper">
<input
id="user-status"
type="text"
bind:value={localStatus}
placeholder="Set a status"
maxlength="128"
/>
<button class="btn-clear" onclick={() => localStatus = ""} aria-label="Clear status">
<i class="fas fa-times-circle"></i>
</button>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="biography">Biography</label> <label for="biography">Biography</label>
<textarea <textarea
+5
View File
@@ -7,6 +7,7 @@ export class AccountService {
#uploadAvatarReducer = useReducer(reducers.uploadAvatar); #uploadAvatarReducer = useReducer(reducers.uploadAvatar);
#setBannerReducer = useReducer(reducers.setBanner); #setBannerReducer = useReducer(reducers.setBanner);
#setBiographyReducer = useReducer(reducers.setBiography); #setBiographyReducer = useReducer(reducers.setBiography);
#setStatusReducer = useReducer(reducers.setStatus);
#uploadBannerReducer = useReducer(reducers.uploadBanner); #uploadBannerReducer = useReducer(reducers.uploadBanner);
handleSetName = (name: string) => { handleSetName = (name: string) => {
@@ -31,6 +32,10 @@ export class AccountService {
this.#setBiographyReducer({ biography }); this.#setBiographyReducer({ biography });
}; };
handleSetStatus = (status?: string) => {
this.#setStatusReducer({ status });
};
uploadBanner = (data: Uint8Array, mimeType: string) => { uploadBanner = (data: Uint8Array, mimeType: string) => {
this.#uploadBannerReducer({ data, mimeType }); this.#uploadBannerReducer({ data, mimeType });
}; };
+4
View File
@@ -446,6 +446,10 @@ export class ChatService {
this.#account.handleSetBiography(biography); this.#account.handleSetBiography(biography);
}; };
handleSetStatus = (status?: string) => {
this.#account.handleSetStatus(status);
};
getAvatarUrl = (user: Types.User | null | undefined) => { getAvatarUrl = (user: Types.User | null | undefined) => {
if (!user || !user.avatarId) return null; if (!user || !user.avatarId) return null;
return this.#avatarUrls.get(user.avatarId.toString()) || null; return this.#avatarUrls.get(user.avatarId.toString()) || null;