loading skeleton

This commit is contained in:
2026-04-08 23:20:22 -04:00
parent a7d1a6dc3b
commit f44d2810bf
9 changed files with 180 additions and 91 deletions
+13 -1
View File
@@ -6,11 +6,23 @@
import VoiceChannelGroup from "./channels/VoiceChannelGroup.svelte";
import DirectMessageList from "./channels/DirectMessageList.svelte";
import Modal from "./Modal.svelte";
import Skeleton from "./Skeleton.svelte";
const chat = getContext<ChatService>("chat");
</script>
{#if !chat.activeServer}
{#if !chat.isReady}
<div class="channel-sidebar">
<div class="server-header">
<Skeleton width="120px" height="1.25rem" />
</div>
<div style="padding: 16px; display: flex; flex-direction: column; gap: 12px;">
{#each Array(6) as _}
<Skeleton width="180px" height="1rem" />
{/each}
</div>
</div>
{:else if !chat.activeServer}
<div class="channel-sidebar">
<div class="server-header">Home</div>
<DirectMessageList />
+61 -46
View File
@@ -3,6 +3,7 @@
import type { ChatService } from "../services/chat.svelte";
import type * as Types from "../../module_bindings/types";
import Avatar from "./Avatar.svelte";
import Skeleton from "./Skeleton.svelte";
const chat = getContext<ChatService>("chat");
@@ -38,59 +39,73 @@
<div class="right-sidebar">
<div class="member-list">
{#if onlineMembers.length > 0}
{#if !chat.isReady}
<div class="member-list-section-header">
ONLINE — {onlineMembers.length}
MEMBERS
</div>
{#each onlineMembers as member (member.identity.toHexString())}
{@const status = getStatus(member)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, member)}>
<Avatar user={member} size="tiny" isTalking={isTalking(member)} />
<div class="member-details">
<span class="member-name {isTalking(member) ? 'talking' : ''}">
{member.name || member.identity.toHexString().substring(0, 8)}
{#if isMe(member)}
<span class="me-badge">(You)</span>
{/if}
</span>
{#if status}
<div class="member-status" title={status}>{status}</div>
{/if}
{#each Array(10) as _}
<div class="member-item">
<Skeleton circle width="32px" />
<div style="margin-left: 12px;">
<Skeleton width="100px" height="0.9rem" />
</div>
<div style="flex: 1;"></div>
{#if isSharing(member)}
<span class="sharing-badge">LIVE</span>
{/if}
<div class="status-dot green"></div>
</div>
{/each}
{/if}
{:else}
{#if onlineMembers.length > 0}
<div class="member-list-section-header">
ONLINE — {onlineMembers.length}
</div>
{#each onlineMembers as member (member.identity.toHexString())}
{@const status = getStatus(member)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, member)}>
<Avatar user={member} size="tiny" isTalking={isTalking(member)} />
<div class="member-details">
<span class="member-name {isTalking(member) ? 'talking' : ''}">
{member.name || member.identity.toHexString().substring(0, 8)}
{#if isMe(member)}
<span class="me-badge">(You)</span>
{/if}
</span>
{#if status}
<div class="member-status" title={status}>{status}</div>
{/if}
</div>
<div style="flex: 1;"></div>
{#if isSharing(member)}
<span class="sharing-badge">LIVE</span>
{/if}
<div class="status-dot green"></div>
</div>
{/each}
{/if}
{#if offlineMembers.length > 0}
<div class="member-list-section-header">
OFFLINE — {offlineMembers.length}
</div>
{#each offlineMembers as member (member.identity.toHexString())}
{@const status = getStatus(member)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, member)}>
<Avatar user={member} size="tiny" />
<div class="member-details">
<span class="member-name">
{member.name || member.identity.toHexString().substring(0, 8)}
{#if isMe(member)}
<span class="me-badge">(You)</span>
{/if}
</span>
{#if status}
<div class="member-status" title={status}>{status}</div>
{/if}
</div>
<div style="flex: 1;"></div>
<div class="status-dot grey"></div>
{#if offlineMembers.length > 0}
<div class="member-list-section-header">
OFFLINE — {offlineMembers.length}
</div>
{/each}
{#each offlineMembers as member (member.identity.toHexString())}
{@const status = getStatus(member)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, member)}>
<Avatar user={member} size="tiny" />
<div class="member-details">
<span class="member-name">
{member.name || member.identity.toHexString().substring(0, 8)}
{#if isMe(member)}
<span class="me-badge">(You)</span>
{/if}
</span>
{#if status}
<div class="member-status" title={status}>{status}</div>
{/if}
</div>
<div style="flex: 1;"></div>
<div class="status-dot grey"></div>
</div>
{/each}
{/if}
{/if}
</div>
</div>
+4 -4
View File
@@ -55,11 +55,11 @@
const newHeight = Math.min(editTextareaRef.scrollHeight, maxHeight);
editTextareaRef.style.height = `${newHeight}px`;
editTextareaRef.style.overflowY = editTextareaRef.scrollHeight > maxHeight ? 'auto' : 'hidden';
// If height changed and we are editing, notify parent to re-check scroll
if (oldHeight !== editTextareaRef.style.height && onContentLoad) {
onContentLoad();
// Also ensure the textarea itself is in view if it expanded
editTextareaRef.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
@@ -82,10 +82,10 @@
editTextareaRef.focus();
// Place cursor at end
editTextareaRef.setSelectionRange(localEditingText.length, localEditingText.length);
// Notify parent to re-check scroll (especially if this is the last message)
if (onContentLoad) onContentLoad();
// Ensure the edit view is fully visible
editTextareaRef.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
+17 -1
View File
@@ -3,6 +3,7 @@
import type { ChatService } from "../services/chat.svelte";
import MessageItem from "./MessageItem.svelte";
import Avatar from "./Avatar.svelte";
import Skeleton from "./Skeleton.svelte";
const chat = getContext<ChatService>("chat");
@@ -187,7 +188,22 @@
bind:this={scrollContainer}
onscroll={handleScroll}
>
{#if chat.activeChannelId}
{#if !chat.isReady || (chat.activeChannelId && !chat.isMessagesReady)}
<div style="padding: 16px; display: flex; flex-direction: column; gap: 24px;">
{#each Array(8) as _}
<div style="display: flex; gap: 16px; align-items: flex-start;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--background-modifier-accent);">
<Skeleton circle width="40px" />
</div>
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">
<Skeleton width="120px" height="1rem" />
<Skeleton width="80%" height="0.85rem" />
<Skeleton width="60%" height="0.85rem" />
</div>
</div>
{/each}
</div>
{:else if chat.activeChannelId}
{#if !chat.hasMoreMessages}
{@const dm = chat.activeDms.find(d => d.channelId === chat.activeChannelId)}
{#if chat.activeServer}
+23 -14
View File
@@ -4,6 +4,7 @@
import { getStdbHost, getStdbDbName } from "../../config";
import Tooltip from "./Tooltip.svelte";
import Modal from "./Modal.svelte";
import Skeleton from "./Skeleton.svelte";
const chat = getContext<ChatService>("chat");
@@ -26,20 +27,28 @@
<div class="sidebar-separator" style="width: 32px; margin: 4px 0; background-color: var(--background-modifier-accent); height: 2px;"></div>
{#each chat.joinedServers as server (server.id.toString())}
{@const avatarUrl = chat.getServerAvatarUrl(server)}
<Tooltip text={server.name} position="right">
<button
class="server-icon {chat.activeServerId === server.id ? 'active' : ''}"
onclick={() => (chat.activeServerId = server.id)}
style={avatarUrl ? `background-image: url(${avatarUrl}); background-size: cover; background-position: center; border: none;` : ""}
>
{#if !avatarUrl}
{server.name.substring(0, 2).toUpperCase()}
{/if}
</button>
</Tooltip>
{/each}
{#if !chat.isReady}
{#each Array(3) as _}
<div style="padding: 4px 0;">
<Skeleton circle width="48px" />
</div>
{/each}
{:else}
{#each chat.joinedServers as server (server.id.toString())}
{@const avatarUrl = chat.getServerAvatarUrl(server)}
<Tooltip text={server.name} position="right">
<button
class="server-icon {chat.activeServerId === server.id ? 'active' : ''}"
onclick={() => (chat.activeServerId = server.id)}
style={avatarUrl ? `background-image: url(${avatarUrl}); background-size: cover; background-position: center; border: none;` : ""}
>
{#if !avatarUrl}
{server.name.substring(0, 2).toUpperCase()}
{/if}
</button>
</Tooltip>
{/each}
{/if}
<Tooltip text="Create Server" position="right">
<button
@@ -2,6 +2,7 @@
import { getContext } from "svelte";
import type { ChatService } from "../../services/chat.svelte";
import Avatar from "../Avatar.svelte";
import Skeleton from "../Skeleton.svelte";
const chat = getContext<ChatService>("chat");
@@ -16,28 +17,40 @@
<div class="dm-header">Direct Messages</div>
<div class="dm-list">
{#each chat.activeDms as dm (dm.id.toString())}
{@const recipient = getRecipient(dm)}
{#if recipient}
<button
class="dm-item {chat.activeChannelId === dm.channelId ? 'active' : ''}"
onclick={() => (chat.activeChannelId = dm.channelId)}
>
<div class="avatar-wrapper">
<Avatar user={recipient} size={32} />
{#if !chat.isReady}
{#each Array(4) as _}
<div class="dm-item">
<Skeleton circle width="32px" />
<div style="margin-left: 12px; display: flex; flex-direction: column; gap: 4px;">
<Skeleton width="80px" height="0.9rem" />
<Skeleton width="120px" height="0.75rem" />
</div>
<div class="dm-info">
<span class="dm-name">{recipient.name || 'Unknown User'}</span>
<span class="dm-status">{recipient.status || ''}</span>
</div>
</button>
{/if}
{/each}
</div>
{/each}
{:else}
{#each chat.activeDms as dm (dm.id.toString())}
{@const recipient = getRecipient(dm)}
{#if recipient}
<button
class="dm-item {chat.activeChannelId === dm.channelId ? 'active' : ''}"
onclick={() => (chat.activeChannelId = dm.channelId)}
>
<div class="avatar-wrapper">
<Avatar user={recipient} size={32} />
</div>
<div class="dm-info">
<span class="dm-name">{recipient.name || 'Unknown User'}</span>
<span class="dm-status">{recipient.status || ''}</span>
</div>
</button>
{/if}
{/each}
{#if chat.activeDms.length === 0}
<div class="empty-dms">
No active direct messages.
</div>
{#if chat.activeDms.length === 0}
<div class="empty-dms">
No active direct messages.
</div>
{/if}
{/if}
</div>
</div>
+7
View File
@@ -326,6 +326,13 @@ export class ChatService {
get isUsersReady() {
return this.#db.isUsersReady;
}
get isReady() {
return this.#db.isReady;
}
get isMessagesReady() {
return this.#msg.isMessagesReady;
}
get maxMessageLength() {
const config = this.#db.systemConfiguration.find(c => c.key === "max_message_length");
+16 -4
View File
@@ -21,6 +21,14 @@ export class DatabaseService {
systemConfiguration = $state<readonly Types.SystemConfiguration[]>([]);
uploadStatus = $state<readonly Types.UploadStatus[]>([]);
isUsersReady = $state(false);
isServersReady = $state(false);
isChannelsReady = $state(false);
isMembersReady = $state(false);
isImagesReady = $state(false);
get isReady() {
return this.isUsersReady && this.isServersReady && this.isChannelsReady && this.isMembersReady;
}
imagesMap = $derived.by(() => {
const map = new Map<bigint, Types.VisibleImageRow>();
@@ -31,14 +39,14 @@ export class DatabaseService {
});
constructor(identity: () => Identity | null) {
const [serversStore] = useTable(tables.visible_servers);
const [channelsStore] = useTable(tables.visible_channels);
const [serversStore, serversReadyStore] = useTable(tables.visible_servers);
const [channelsStore, channelsReadyStore] = useTable(tables.visible_channels);
const [directMessagesStore] = useTable(tables.visible_direct_messages);
const [usersStore, usersReadyStore] = useTable(tables.user);
const [voiceStatesStore] = useTable(tables.voice_state);
const [serverMembersStore] = useTable(tables.visible_server_members);
const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members);
const [threadsStore] = useTable(tables.thread);
const [imagesStore] = useTable(tables.visible_images);
const [imagesStore, imagesReadyStore] = useTable(tables.visible_images);
const [customEmojisStore] = useTable(tables.custom_emoji);
const [voiceActivityStore] = useTable(tables.voice_activity);
const [typingActivityStore] = useTable(tables.typing_activity);
@@ -47,12 +55,15 @@ export class DatabaseService {
const [uploadStatusStore] = useTable(tables.upload_status);
serversStore.subscribe((v) => (this.servers = v));
serversReadyStore.subscribe((v) => (this.isServersReady = v));
channelsStore.subscribe((v) => (this.channels = v));
channelsReadyStore.subscribe((v) => (this.isChannelsReady = v));
directMessagesStore.subscribe((v) => (this.directMessages = v));
usersStore.subscribe((v) => (this.users = v));
usersReadyStore.subscribe((v) => (this.isUsersReady = v));
voiceStatesStore.subscribe((v) => (this.voiceStates = v));
serverMembersStore.subscribe((v) => (this.serverMembers = v));
membersReadyStore.subscribe((v) => (this.isMembersReady = v));
threadsStore.subscribe((v) => (this.allThreads = v));
imagesStore.subscribe((v) => {
// CRITICAL: We MUST copy the Uint8Array data immediately.
@@ -64,6 +75,7 @@ export class DatabaseService {
data: new Uint8Array(img.data)
}));
});
imagesReadyStore.subscribe((v) => (this.isImagesReady = v));
customEmojisStore.subscribe((v) => (this.customEmojis = v));
voiceActivityStore.subscribe((v) => (this.voiceActivity = v));
typingActivityStore.subscribe((v) => (this.typingActivity = v));
+6 -1
View File
@@ -35,6 +35,7 @@ export class MessagingService {
}>();
isLoadingMore = $state(false);
isMessagesReady = $state(false);
constructor(
db: DatabaseService,
@@ -57,7 +58,7 @@ export class MessagingService {
this.#extendSubscriptionReducer = useReducer(reducers.extendSubscription);
this.#clearUploadStatusReducer = useReducer(reducers.clearUploadStatus);
const [visibleMessagesStore] = useTable(tables.visible_messages);
const [visibleMessagesStore, visibleMessagesReadyStore] = useTable(tables.visible_messages);
const [visibleDmMessagesStore] = useTable(tables.visible_dm_messages);
const [visibleScrollbackStore] = useTable(tables.visible_scrollback_messages);
const [mySubscriptionsStore] = useTable(tables.my_channel_subscriptions);
@@ -74,6 +75,10 @@ export class MessagingService {
this.#updateBuckets([...serverMessages, ...dmMessages, ...scrollbackMessages]);
});
visibleMessagesReadyStore.subscribe((v) => {
this.isMessagesReady = v;
});
visibleDmMessagesStore.subscribe((v) => {
dmMessages = v;
this.#updateBuckets([...serverMessages, ...dmMessages, ...scrollbackMessages]);