user statuses
This commit is contained in:
@@ -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:
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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) */
|
||||
|
||||
@@ -39,12 +39,17 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, user)}>
|
||||
<Avatar user={user} size="tiny" isTalking={isTalking(user)} />
|
||||
<span class="member-name {isTalking(user) ? 'talking' : ''}">
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{#if isMe(user)}
|
||||
<span class="me-badge">(You)</span>
|
||||
<div class="member-details">
|
||||
<span class="member-name {isTalking(user) ? 'talking' : ''}">
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{#if isMe(user)}
|
||||
<span class="me-badge">(You)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if user.status}
|
||||
<div class="member-status" title={user.status}>{user.status}</div>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div style="flex: 1;"></div>
|
||||
{#if isSharing(user)}
|
||||
<span class="sharing-badge">LIVE</span>
|
||||
@@ -62,12 +67,17 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, user)}>
|
||||
<Avatar user={user} size="tiny" />
|
||||
<span class="member-name">
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{#if isMe(user)}
|
||||
<span class="me-badge">(You)</span>
|
||||
<div class="member-details">
|
||||
<span class="member-name">
|
||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||
{#if isMe(user)}
|
||||
<span class="me-badge">(You)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if user.status}
|
||||
<div class="member-status" title={user.status}>{user.status}</div>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div style="flex: 1;"></div>
|
||||
<div class="status-dot grey"></div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
let messagesEndRef = $state<HTMLDivElement | null>(null);
|
||||
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||
let lastChannelId = $state<bigint | null>(null);
|
||||
let lastMessageId = $state<bigint | null>(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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If we ARE at the bottom, OR we just sent a message, keep us pinned to the NEW bottom
|
||||
if (isAtBottom || sentByMe) {
|
||||
// 2. If we are AT THE BOTTOM, stay pinned.
|
||||
if (isAtBottom) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
const chat = getContext<ChatService>("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 @@
|
||||
<span class="username">@{user.identity.toHexString().substring(0, 8)}</span>
|
||||
</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">
|
||||
<h3>About Me</h3>
|
||||
<p class="biography">{user.biography || "No biography yet."}</p>
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
let activeCategory = $state("account");
|
||||
let localName = $state("");
|
||||
let localStatus = $state("");
|
||||
let biography = $state("");
|
||||
let avatarPreview = $state<string | null>(null);
|
||||
let newAvatarFile = $state<File | null>(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"}
|
||||
<AccountSettings
|
||||
bind:localName
|
||||
bind:localStatus
|
||||
bind:biography
|
||||
bind:avatarPreview
|
||||
bind:newAvatarFile
|
||||
@@ -180,6 +190,7 @@
|
||||
<div class="footer-actions">
|
||||
<button class="btn-ghost" onclick={() => {
|
||||
localName = currentUser?.name || "";
|
||||
localStatus = currentUser?.status || "";
|
||||
biography = currentUser?.biography || "";
|
||||
avatarPreview = chat.getAvatarUrl(currentUser);
|
||||
bannerPreview = chat.getBannerUrl(currentUser);
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||
let isAtBottom = $state(true);
|
||||
let lastThreadId = $state<bigint | null>(null);
|
||||
let lastMessageId = $state<bigint | null>(null);
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollContainer) return;
|
||||
@@ -89,16 +90,30 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Track threadMessages and activeThreadId
|
||||
const messages = threadMessages;
|
||||
const currentThreadId = activeThreadId;
|
||||
|
||||
if (messages.length > 0 || parentMessage) {
|
||||
const isThreadSwitch = currentThreadId !== lastThreadId;
|
||||
lastThreadId = currentThreadId;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const isNewLastMessage = lastMsg && lastMsg.id !== lastMessageId;
|
||||
|
||||
if (isThreadSwitch || isAtBottom) {
|
||||
scrollToBottom(!isThreadSwitch);
|
||||
if (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 {
|
||||
localName = $bindable(),
|
||||
localStatus = $bindable(),
|
||||
biography = $bindable(),
|
||||
avatarPreview = $bindable(),
|
||||
newAvatarFile = $bindable(),
|
||||
@@ -14,6 +15,7 @@
|
||||
currentUser,
|
||||
}: {
|
||||
localName: string;
|
||||
localStatus: string;
|
||||
biography: string;
|
||||
avatarPreview: string | null;
|
||||
newAvatarFile: File | null;
|
||||
@@ -99,6 +101,22 @@
|
||||
</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">
|
||||
<label for="biography">Biography</label>
|
||||
<textarea
|
||||
|
||||
@@ -7,6 +7,7 @@ export class AccountService {
|
||||
#uploadAvatarReducer = useReducer(reducers.uploadAvatar);
|
||||
#setBannerReducer = useReducer(reducers.setBanner);
|
||||
#setBiographyReducer = useReducer(reducers.setBiography);
|
||||
#setStatusReducer = useReducer(reducers.setStatus);
|
||||
#uploadBannerReducer = useReducer(reducers.uploadBanner);
|
||||
|
||||
handleSetName = (name: string) => {
|
||||
@@ -31,6 +32,10 @@ export class AccountService {
|
||||
this.#setBiographyReducer({ biography });
|
||||
};
|
||||
|
||||
handleSetStatus = (status?: string) => {
|
||||
this.#setStatusReducer({ status });
|
||||
};
|
||||
|
||||
uploadBanner = (data: Uint8Array, mimeType: string) => {
|
||||
this.#uploadBannerReducer({ data, mimeType });
|
||||
};
|
||||
|
||||
@@ -446,6 +446,10 @@ export class ChatService {
|
||||
this.#account.handleSetBiography(biography);
|
||||
};
|
||||
|
||||
handleSetStatus = (status?: string) => {
|
||||
this.#account.handleSetStatus(status);
|
||||
};
|
||||
|
||||
getAvatarUrl = (user: Types.User | null | undefined) => {
|
||||
if (!user || !user.avatarId) return null;
|
||||
return this.#avatarUrls.get(user.avatarId.toString()) || null;
|
||||
|
||||
Reference in New Issue
Block a user