user statuses
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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) */
|
||||||
|
|||||||
@@ -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)} />
|
||||||
<span class="member-name {isTalking(user) ? 'talking' : ''}">
|
<div class="member-details">
|
||||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
<span class="member-name {isTalking(user) ? 'talking' : ''}">
|
||||||
{#if isMe(user)}
|
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||||
<span class="me-badge">(You)</span>
|
{#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}
|
{/if}
|
||||||
</span>
|
</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" />
|
||||||
<span class="member-name">
|
<div class="member-details">
|
||||||
{user.name || user.identity.toHexString().substring(0, 8)}
|
<span class="member-name">
|
||||||
{#if isMe(user)}
|
{user.name || user.identity.toHexString().substring(0, 8)}
|
||||||
<span class="me-badge">(You)</span>
|
{#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}
|
{/if}
|
||||||
</span>
|
</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>
|
||||||
|
|||||||
@@ -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,20 +109,26 @@
|
|||||||
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) {
|
||||||
// Aggressive instant scroll for self-sent messages
|
lastMessageId = lastMsg.id;
|
||||||
scrollToBottom(false);
|
if (sentByMe) {
|
||||||
} else if (isAtBottom && !isPrepending) {
|
// Aggressive instant scroll for self-sent messages
|
||||||
scrollToBottom(true);
|
scrollToBottom(false);
|
||||||
|
} else if (isAtBottom && !isPrepending) {
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user