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)}
LIVE
@@ -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}
+
+ {: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"}
+
+