loading skeleton
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user