From 9f72830f145616903f600eb6b9f9fd69d6fe612a Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Sun, 18 Oct 2026 20:18:00 -0400 Subject: [PATCH 1/3] wip --- src/lib/attachments/scroll-history.svelte.ts | 89 ++++++++ src/lib/components/user/User.svelte | 195 +--------------- src/lib/components/user/UserCard.svelte | 228 +++++++++++++++++++ src/lib/graphql/twitch.ts | 83 +++++-- src/lib/models/message-history.svelte.ts | 135 +++++++++++ src/lib/models/viewer.svelte.ts | 11 + src/lib/models/whisper.svelte.ts | 70 ++---- src/lib/pager.svelte.ts | 96 ++++++++ 8 files changed, 649 insertions(+), 258 deletions(-) create mode 100644 src/lib/attachments/scroll-history.svelte.ts create mode 100644 src/lib/components/user/UserCard.svelte create mode 100644 src/lib/models/message-history.svelte.ts create mode 100644 src/lib/pager.svelte.ts diff --git a/src/lib/attachments/scroll-history.svelte.ts b/src/lib/attachments/scroll-history.svelte.ts new file mode 100644 index 00000000..03ec5bc0 --- /dev/null +++ b/src/lib/attachments/scroll-history.svelte.ts @@ -0,0 +1,89 @@ +import { tick } from "svelte"; +import type { Attachment } from "svelte/attachments"; +import { on } from "svelte/events"; + +import { log } from "#lib/log.js"; + +export interface ScrollableHistory { + /** + * The loaded messages, oldest first. + */ + readonly messages: readonly T[]; + readonly hasOlder: boolean; + loadOlder(): Promise; +} + +export interface ScrollHistoryOptions { + /** + * The distance from the top in pixels at which older messages are loaded. + * + * @default 200 + */ + threshold?: number; +} + +export function scrollHistory( + history: ScrollableHistory, + { threshold = 200 }: ScrollHistoryOptions = {}, +): Attachment { + return (element) => { + let atBottom = true; + let visible = false; + let loading = false; + + function scrollToBottom() { + element.scrollTo(0, element.scrollHeight); + } + + async function loadOlder() { + if (loading || !history.hasOlder || element.scrollTop > threshold) return; + + loading = true; + + try { + const height = element.scrollHeight; + + await history.loadOlder(); + await tick(); + + // Keep the current messages in place as older ones are prepended. + element.scrollTop += element.scrollHeight - height; + } catch (error) { + void log.error(`Failed to load older messages: ${String(error)}`).catch(() => {}); + } finally { + loading = false; + } + } + + // Elements can't be scrolled while hidden, e.g. inside a closed popover, + // so start at the newest message whenever the list is shown and stay + // there through resizes. + const observer = new ResizeObserver(() => { + const shown = element.offsetHeight > 0; + + if (shown && !visible) atBottom = true; + visible = shown; + + if (atBottom) scrollToBottom(); + }); + + observer.observe(element); + + $effect.pre(() => { + void history.messages.length; + + if (atBottom) void tick().then(scrollToBottom); + }); + + const off = on(element, "scroll", () => { + atBottom = element.offsetHeight + element.scrollTop > element.scrollHeight - 20; + + void loadOlder(); + }); + + return () => { + off(); + observer.disconnect(); + }; + }; +} diff --git a/src/lib/components/user/User.svelte b/src/lib/components/user/User.svelte index 8ceecfda..18deecd3 100644 --- a/src/lib/components/user/User.svelte +++ b/src/lib/components/user/User.svelte @@ -1,25 +1,10 @@ + + { + if (event.newState !== "open") return; + + void fetchInfo(); + void loadHistory(); + }} + ontoggle={async (event) => { + if (event.newState !== "closed") return; + + const popover = event.currentTarget; + + await Promise.allSettled(popover.getAnimations().map((animation) => animation.finished)); + + if (!popover.matches(":popover-open")) onclose?.(); + }} +> +
+ {#if user.bannerUrl} + + {/if} +
+ +
+ +
+ + + + + +
+
+ +
+
+ + + {#if loading} + Loading... + {:else} + + {/if} +
+ +
+ + + {#if loading} + Loading... + {:else if relationship?.followedAt} + + {:else} + Not following + {/if} +
+ +
+ {#if !relationship?.subscription.tier} + + {:else} + + {/if} + + {#if loading} + Loading... + {:else if relationship?.subscription.months} + {@const { tier, type, months } = relationship.subscription} + {@const noun = `month${months > 1 ? "s" : ""}`} + + {#if tier} + {type === "prime" ? "Prime" : `Tier ${tier}`} ({months} {noun}) + {:else} + {months} {noun} + {/if} + {:else} + Subscription unknown + {/if} +
+
+ +
+ {user.displayName} + + {#if relationship?.badges.length} + {@const badges = showAllBadges + ? relationship.badges + : relationship.badges.slice(0, 10)} + +
+ {#each badges as badge (badge.id)} + {badge.description} + {/each} + + {#if !showAllBadges && relationship.badges.length > 10} + + {/if} +
+ {/if} + + {#if user.bio} +

{user.bio}

+ {/if} +
+
+ + {#if history && messages.length} +
+ message.id} + lazy={history.hasOlder} + onloadmore={() => history.loadOlder()} + > + {#snippet children(message)} +
+ +
+ {/snippet} +
+
+ {:else if loadingHistory} +

Loading message history...

+ {/if} +
diff --git a/src/lib/graphql/twitch.ts b/src/lib/graphql/twitch.ts index 6c95e397..32631183 100644 --- a/src/lib/graphql/twitch.ts +++ b/src/lib/graphql/twitch.ts @@ -63,6 +63,18 @@ const guestStarDetailsFragment = gql(` } `); +const messageFragmentContentFragment = gql(` + fragment MessageFragmentContent on FragmentContent { + __typename + ... on CheermoteToken { + bitsAmount + } + ... on Emote { + emoteID: id + } + } +`); + const userDetailsFragment = gql(` fragment UserDetails on User { id @@ -275,6 +287,55 @@ export const moderatesQuery = gql(` } `); +export const modLogMessagesQuery = gql( + `query GetModLogMessages($channel: ID!, $target: ID!, $after: Cursor) { + viewerCardModLogs(channelID: $channel, targetID: $target) { + messages(first: 50, after: $after) { + __typename + ... on ViewerCardModLogsMessagesConnection { + edges { + cursor + node { + __typename + ... on ViewerCardModLogsChatMessage { + id + sentAt + isDeleted + content { + text + fragments { + text + content { + ...MessageFragmentContent + } + } + } + sender { + id + login + name: displayName + chatColor + } + senderBadges { + name: setID + version + } + } + } + } + pageInfo { + hasNextPage + } + } + ... on ViewerCardModLogsMessagesError { + code + } + } + } + }`, + [messageFragmentContentFragment], +); + export const modsQuery = gql(` query GetMods($id: ID!) { user(id: $id) { @@ -309,13 +370,7 @@ export const pinnedMessageQuery = gql( fragments { text content { - __typename - ... on CheermoteToken { - bitsAmount - } - ... on Emote { - emoteID: id - } + ...MessageFragmentContent } } } @@ -334,7 +389,7 @@ export const pinnedMessageQuery = gql( } } }`, - [badgeDetailsFragment], + [badgeDetailsFragment, messageFragmentContentFragment], ); export const pollQuery = gql( @@ -889,12 +944,12 @@ export type ChannelSuggestion = Extract< { __typename: "SearchSuggestionChannel" } >; -type MessageContent = NonNullableDeep; - -type PinnedMessage = NonNullableDeep< - ResultOf, - "channel.pinnedChatMessages.edges.0.node" ->; +interface MessageContent { + fragments: readonly { + text: string; + content: FragmentOf | null; + }[]; +} type Poll = NonNullableDeep, "user.viewablePoll">; diff --git a/src/lib/models/message-history.svelte.ts b/src/lib/models/message-history.svelte.ts new file mode 100644 index 00000000..719fe5ed --- /dev/null +++ b/src/lib/models/message-history.svelte.ts @@ -0,0 +1,135 @@ +import type { ResultOf } from "gql.tada"; + +import type { ScrollableHistory } from "#lib/attachments/scroll-history.svelte.js"; +import type { NonNullableDeep } from "#lib/graphql/index.js"; + +import { modLogMessagesQuery, toMessageFragments } from "#lib/graphql/twitch.js"; +import { Pager, prependUnique, toPage, type Page } from "#lib/pager.svelte.js"; + +import type { Viewer } from "./viewer.svelte"; + +import { UserMessage } from "./message/user-message.svelte"; + +type ModLogsResult = NonNullableDeep< + ResultOf, + "viewerCardModLogs.messages" +>; + +type ModLogMessage = NonNullable< + Extract< + ModLogsResult, + { __typename: "ViewerCardModLogsMessagesConnection" } + >["edges"][number]["node"] +>; + +/** + * The messages a viewer has sent in a channel. + */ +export class MessageHistory implements ScrollableHistory { + readonly #pager = new Pager((after) => this.#fetchPage(after)); + + #history: Promise | null = null; + + // Messages loaded from the mod logs, oldest first. + #logged = $state.raw([]); + + /** + * The messages sent by the viewer, oldest first. Only includes messages + * received in chat until {@linkcode load} is called. + */ + public readonly messages = $derived.by(() => { + const received = this.viewer.channel.chat.messages.filter( + (m): m is UserMessage => m.isUser() && m.author.id === this.viewer.id, + ); + + if (!this.#logged.length) return received; + + // Prefer messages received in chat since they're kept up to date. + const ids = new Set(received.map((message) => message.id)); + + return [...this.#logged.filter((message) => !ids.has(message.id)), ...received].toSorted( + (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), + ); + }); + + public constructor(public readonly viewer: Viewer) {} + + /** + * Whether there are older messages that can be loaded with + * {@linkcode loadOlder}. + */ + public get hasOlder() { + return this.#pager.hasOlder; + } + + /** + * Loads the most recent page of messages from the mod logs if the current + * user is a moderator in the channel. + */ + public load() { + if (!this.viewer.channel.isMod) { + return Promise.resolve(); + } + + return (this.#history ??= this.#loadHistory()); + } + + /** + * Loads the page of messages before the oldest loaded message. + */ + public async loadOlder() { + const older = await this.#pager.older(); + + this.#logged = prependUnique(this.#logged, older); + } + + async #loadHistory() { + try { + this.#logged = await this.#pager.first(); + } catch (error) { + this.#history = null; + + throw error; + } + } + + async #fetchPage(after: string | null): Promise> { + const { channel } = this.viewer; + + const { viewerCardModLogs } = await channel.client.gql(modLogMessagesQuery, { + channel: channel.id, + target: this.viewer.id, + after, + }); + + const result = viewerCardModLogs?.messages; + + if (result?.__typename === "ViewerCardModLogsMessagesError") { + throw new Error(`viewerCardModLogs failed: ${result.code}`); + } + + return toPage(result, (message) => this.#transform(message)); + } + + #transform(data: ModLogMessage) { + if (data.__typename !== "ViewerCardModLogsChatMessage") return null; + + const message = UserMessage.from(this.viewer.channel, { + id: data.id, + text: data.content.text, + fragments: toMessageFragments(data.content), + sender: data.sender ?? { + id: this.viewer.id, + login: this.viewer.username, + name: this.viewer.displayName, + }, + color: data.sender?.chatColor ?? "", + badges: data.senderBadges ?? [], + timestamp: new Date(data.sentAt).getTime(), + }); + + message.deleted = data.isDeleted; + + return message; + } +} diff --git a/src/lib/models/viewer.svelte.ts b/src/lib/models/viewer.svelte.ts index 633461a0..fa343b17 100644 --- a/src/lib/models/viewer.svelte.ts +++ b/src/lib/models/viewer.svelte.ts @@ -1,6 +1,8 @@ import type { Channel } from "./channel.svelte"; import type { User } from "./user.svelte"; +import { MessageHistory } from "./message-history.svelte"; + export interface TimeoutOptions { /** * The duration of the timeout in seconds. @@ -14,6 +16,8 @@ export interface TimeoutOptions { } export class Viewer { + #history: MessageHistory | null = null; + public readonly id: string; public readonly username: string; public readonly displayName: string; @@ -94,6 +98,13 @@ export class Viewer { return this.monitored || this.restricted || this.possibleBanEvader; } + /** + * The messages the viewer has sent in the channel. + */ + public get history() { + return (this.#history ??= new MessageHistory(this)); + } + public ban(reason?: string) { return this.channel.viewers.ban(this.username, reason); } diff --git a/src/lib/models/whisper.svelte.ts b/src/lib/models/whisper.svelte.ts index 7eb21a77..397a9f65 100644 --- a/src/lib/models/whisper.svelte.ts +++ b/src/lib/models/whisper.svelte.ts @@ -1,3 +1,4 @@ +import type { ScrollableHistory } from "#lib/attachments/scroll-history.svelte.js"; import type { WhisperMessage as ApiWhisperMessage, WhisperThread as ApiWhisperThread, @@ -5,12 +6,12 @@ import type { import type { TwitchClient } from "#lib/twitch/client.js"; import { app } from "#lib/app.svelte.js"; -import { nodes } from "#lib/graphql/index.js"; import { markWhisperReadMutation, sendWhisperMutation, whisperMessagesQuery, } from "#lib/graphql/twitch.js"; +import { Pager, prependUnique, toPage, type Page } from "#lib/pager.svelte.js"; import type { Badge } from "./badge"; import type { User } from "./user.svelte"; @@ -23,13 +24,9 @@ export interface WhisperMessage { text: string; } -interface MessagePage { - messages: WhisperMessage[]; - cursor: string | null; - hasNext: boolean; -} +export class Whisper implements ScrollableHistory { + readonly #pager = new Pager((after) => this.#fetchPage(after)); -export class Whisper { // Live messages received while the history request is in flight. #pending: WhisperMessage[] = []; @@ -38,8 +35,6 @@ export class Whisper { #loaded = false; #history: Promise | null = null; - #older: Promise | null = null; - #cursor: string | null = null; // The newest message id known to exist on Twitch. #lastReadableId: string | null = null; @@ -73,12 +68,6 @@ export class Whisper { */ public unread = $state(0); - /** - * Whether there are older messages that can be loaded with - * {@linkcode loadOlder}. - */ - public hasOlder = $state(false); - public constructor( public readonly client: TwitchClient, @@ -89,6 +78,14 @@ export class Whisper { public readonly sender: User, ) {} + /** + * Whether there are older messages that can be loaded with + * {@linkcode loadOlder}. + */ + public get hasOlder() { + return this.#pager.hasOlder; + } + /** * Updates the whisper with the latest state from the thread list. */ @@ -128,23 +125,10 @@ export class Whisper { /** * Loads the page of messages before the oldest loaded message. */ - public loadOlder() { - if (!this.hasOlder) return Promise.resolve(); - - this.#older ??= this.#fetchPage(this.#cursor) - .then((page) => { - this.#paginate(page); - - // Pages can overlap if messages arrive between requests. - const known = new Set(this.messages.map((message) => message.id)); + public async loadOlder() { + const older = await this.#pager.older(); - this.messages.unshift(...page.messages.filter((message) => !known.has(message.id))); - }) - .finally(() => { - this.#older = null; - }); - - return this.#older; + this.messages = prependUnique(this.messages, older); } /** @@ -189,11 +173,7 @@ export class Whisper { this.#pending = []; try { - const page = await this.#fetchPage(null); - - this.#paginate(page); - - const history = page.messages; + const history = await this.#pager.first(); const last = history.at(-1); if (last && !this.#pending.length) { @@ -215,27 +195,13 @@ export class Whisper { } } - #paginate(page: MessagePage) { - this.#cursor = page.cursor; - this.hasOlder = page.hasNext && !!page.cursor; - } - - async #fetchPage(after: string | null): Promise { + async #fetchPage(after: string | null): Promise> { const { whisperThread } = await this.client.gql(whisperMessagesQuery, { id: this.id, after, }); - const connection = whisperThread?.messages; - - return { - // Pages are returned newest first - messages: nodes(connection) - .toReversed() - .map((message) => this.#transform(message)), - cursor: connection?.edges.at(-1)?.cursor ?? null, - hasNext: connection?.pageInfo.hasNextPage ?? false, - }; + return toPage(whisperThread?.messages, (message) => this.#transform(message)); } #transform(message: ApiWhisperMessage): WhisperMessage { diff --git a/src/lib/pager.svelte.ts b/src/lib/pager.svelte.ts new file mode 100644 index 00000000..4210809f --- /dev/null +++ b/src/lib/pager.svelte.ts @@ -0,0 +1,96 @@ +import type { Connection } from "#lib/graphql/index.js"; + +import { nodes } from "#lib/graphql/index.js"; + +export interface Page { + /** + * The items in the page, oldest first. + */ + items: T[]; + cursor: string | null; + hasNext: boolean; +} + +/** + * Creates a page from a connection whose edges are returned newest first. + * Nodes are skipped if `transform` returns `null`. + */ +export function toPage( + connection: Connection | null | undefined, + transform: (node: N) => T | null, +): Page { + return { + items: nodes(connection) + .toReversed() + .map(transform) + .filter((item) => item !== null), + cursor: connection?.edges?.at(-1)?.cursor ?? null, + hasNext: connection?.pageInfo?.hasNextPage ?? false, + }; +} + +/** + * Prepends a page of older items, skipping any that are already present. + * Pages can overlap if items arrive between requests. + */ +export function prependUnique(items: T[], older: T[]) { + const known = new Set(items.map((item) => item.id)); + + return [...older.filter((item) => !known.has(item.id)), ...items]; +} + +/** + * Tracks the cursor of a backwards paginated connection. + */ +export class Pager { + readonly #fetch: (after: string | null) => Promise>; + + #cursor: string | null = null; + #older: Promise | null = null; + + /** + * Whether there are older pages that can be fetched with + * {@linkcode older}. + */ + public hasOlder = $state(false); + + public constructor(fetch: (after: string | null) => Promise>) { + this.#fetch = fetch; + } + + /** + * Fetches the most recent page, resetting the cursor. + */ + public async first() { + const page = await this.#fetch(null); + + this.#paginate(page); + + return page.items; + } + + /** + * Fetches the page before the oldest fetched page. Concurrent calls share + * the same request. + */ + public older() { + if (!this.hasOlder) return Promise.resolve([]); + + this.#older ??= this.#fetch(this.#cursor) + .then((page) => { + this.#paginate(page); + + return page.items; + }) + .finally(() => { + this.#older = null; + }); + + return this.#older; + } + + #paginate(page: Page) { + this.#cursor = page.cursor; + this.hasOlder = page.hasNext && !!page.cursor; + } +} From a480fd3780fda99321fcb6c3dd6a3e2b335ce1fc Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Tue, 20 Oct 2026 17:52:15 -0400 Subject: [PATCH 2/3] clean up --- src/lib/attachments/scroll-history.svelte.ts | 89 -------------------- src/lib/models/message-history.svelte.ts | 3 +- src/lib/models/whisper.svelte.ts | 3 +- 3 files changed, 2 insertions(+), 93 deletions(-) delete mode 100644 src/lib/attachments/scroll-history.svelte.ts diff --git a/src/lib/attachments/scroll-history.svelte.ts b/src/lib/attachments/scroll-history.svelte.ts deleted file mode 100644 index 03ec5bc0..00000000 --- a/src/lib/attachments/scroll-history.svelte.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { tick } from "svelte"; -import type { Attachment } from "svelte/attachments"; -import { on } from "svelte/events"; - -import { log } from "#lib/log.js"; - -export interface ScrollableHistory { - /** - * The loaded messages, oldest first. - */ - readonly messages: readonly T[]; - readonly hasOlder: boolean; - loadOlder(): Promise; -} - -export interface ScrollHistoryOptions { - /** - * The distance from the top in pixels at which older messages are loaded. - * - * @default 200 - */ - threshold?: number; -} - -export function scrollHistory( - history: ScrollableHistory, - { threshold = 200 }: ScrollHistoryOptions = {}, -): Attachment { - return (element) => { - let atBottom = true; - let visible = false; - let loading = false; - - function scrollToBottom() { - element.scrollTo(0, element.scrollHeight); - } - - async function loadOlder() { - if (loading || !history.hasOlder || element.scrollTop > threshold) return; - - loading = true; - - try { - const height = element.scrollHeight; - - await history.loadOlder(); - await tick(); - - // Keep the current messages in place as older ones are prepended. - element.scrollTop += element.scrollHeight - height; - } catch (error) { - void log.error(`Failed to load older messages: ${String(error)}`).catch(() => {}); - } finally { - loading = false; - } - } - - // Elements can't be scrolled while hidden, e.g. inside a closed popover, - // so start at the newest message whenever the list is shown and stay - // there through resizes. - const observer = new ResizeObserver(() => { - const shown = element.offsetHeight > 0; - - if (shown && !visible) atBottom = true; - visible = shown; - - if (atBottom) scrollToBottom(); - }); - - observer.observe(element); - - $effect.pre(() => { - void history.messages.length; - - if (atBottom) void tick().then(scrollToBottom); - }); - - const off = on(element, "scroll", () => { - atBottom = element.offsetHeight + element.scrollTop > element.scrollHeight - 20; - - void loadOlder(); - }); - - return () => { - off(); - observer.disconnect(); - }; - }; -} diff --git a/src/lib/models/message-history.svelte.ts b/src/lib/models/message-history.svelte.ts index 719fe5ed..2791325e 100644 --- a/src/lib/models/message-history.svelte.ts +++ b/src/lib/models/message-history.svelte.ts @@ -1,6 +1,5 @@ import type { ResultOf } from "gql.tada"; -import type { ScrollableHistory } from "#lib/attachments/scroll-history.svelte.js"; import type { NonNullableDeep } from "#lib/graphql/index.js"; import { modLogMessagesQuery, toMessageFragments } from "#lib/graphql/twitch.js"; @@ -25,7 +24,7 @@ type ModLogMessage = NonNullable< /** * The messages a viewer has sent in a channel. */ -export class MessageHistory implements ScrollableHistory { +export class MessageHistory { readonly #pager = new Pager((after) => this.#fetchPage(after)); #history: Promise | null = null; diff --git a/src/lib/models/whisper.svelte.ts b/src/lib/models/whisper.svelte.ts index 397a9f65..78356d9b 100644 --- a/src/lib/models/whisper.svelte.ts +++ b/src/lib/models/whisper.svelte.ts @@ -1,4 +1,3 @@ -import type { ScrollableHistory } from "#lib/attachments/scroll-history.svelte.js"; import type { WhisperMessage as ApiWhisperMessage, WhisperThread as ApiWhisperThread, @@ -24,7 +23,7 @@ export interface WhisperMessage { text: string; } -export class Whisper implements ScrollableHistory { +export class Whisper { readonly #pager = new Pager((after) => this.#fetchPage(after)); // Live messages received while the history request is in flight. From 573d3861cf376ebf419a8337a380226f97778fd8 Mon Sep 17 00:00:00 2001 From: Oliver Rose Date: Wed, 21 Oct 2026 18:42:36 -0400 Subject: [PATCH 3/3] fixes --- src/lib/components/message/AutoMod.svelte | 4 ++-- src/lib/components/message/UserMessage.svelte | 4 ++-- src/lib/components/user/UserCard.svelte | 14 +++++------- src/lib/models/message-history.svelte.ts | 22 +++++++++++++++++-- src/lib/models/viewer.svelte.ts | 11 ---------- src/lib/settings/index.ts | 4 ++++ 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/lib/components/message/AutoMod.svelte b/src/lib/components/message/AutoMod.svelte index 0e0061c3..dcffde00 100644 --- a/src/lib/components/message/AutoMod.svelte +++ b/src/lib/components/message/AutoMod.svelte @@ -2,7 +2,7 @@ import type { AutoModMetadata } from "#lib/models/message/user-message.svelte.js"; import { UserMessage } from "#lib/models/message/user-message.svelte.js"; - import { settings } from "#lib/settings/index.js"; + import { deletedAppearance } from "#lib/settings/index.js"; import Button from "../ui/Button.svelte"; import Message from "./Message.svelte"; @@ -17,7 +17,7 @@
diff --git a/src/lib/components/message/UserMessage.svelte b/src/lib/components/message/UserMessage.svelte index c88924e8..2dc58dfb 100644 --- a/src/lib/components/message/UserMessage.svelte +++ b/src/lib/components/message/UserMessage.svelte @@ -6,7 +6,7 @@ import { app } from "#lib/app.svelte.js"; import { createMessageMenu } from "#lib/menus/message-menu.js"; - import { settings } from "#lib/settings/index.js"; + import { deletedAppearance, settings } from "#lib/settings/index.js"; import { openMenu } from "#lib/util.js"; import Highlight from "./Highlight.svelte"; @@ -73,7 +73,7 @@
openMenu(event, () => createMessageMenu(message))} > {#if !message.deleted && !app.user?.banned.has(message.channel.id)} diff --git a/src/lib/components/user/UserCard.svelte b/src/lib/components/user/UserCard.svelte index fc0fe563..22d9c53f 100644 --- a/src/lib/components/user/UserCard.svelte +++ b/src/lib/components/user/UserCard.svelte @@ -13,7 +13,8 @@ import type { User } from "#lib/models/user.svelte.js"; import { log } from "#lib/log.js"; - import { settings } from "#lib/settings/index.js"; + import { MessageHistory } from "#lib/models/message-history.svelte.js"; + import { deletedAppearance } from "#lib/settings/index.js"; import Message from "../message/Message.svelte"; import ReverseScroll from "../ReverseScroll.svelte"; @@ -35,7 +36,9 @@ let showAllBadges = $state(false); const relationship = $derived(user.relationships.get(channel.id)); - const history = $derived(channel.viewers.get(user.id)?.history); + const viewer = $derived(channel.viewers.get(user.id)); + + const history = $derived(viewer && new MessageHistory(viewer)); const messages = $derived(history?.messages ?? []); async function fetchInfo() { @@ -211,12 +214,7 @@ onloadmore={() => history.loadOlder()} > {#snippet children(message)} -
+
{/snippet} diff --git a/src/lib/models/message-history.svelte.ts b/src/lib/models/message-history.svelte.ts index 2791325e..efae2117 100644 --- a/src/lib/models/message-history.svelte.ts +++ b/src/lib/models/message-history.svelte.ts @@ -46,8 +46,9 @@ export class MessageHistory { // Prefer messages received in chat since they're kept up to date. const ids = new Set(received.map((message) => message.id)); - return [...this.#logged.filter((message) => !ids.has(message.id)), ...received].toSorted( - (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), + return merge( + this.#logged.filter((message) => !ids.has(message.id)), + received, ); }); @@ -132,3 +133,20 @@ export class MessageHistory { return message; } } + +function merge(a: UserMessage[], b: UserMessage[]) { + const merged: UserMessage[] = []; + + let i = 0; + let j = 0; + + while (i < a.length && j < b.length) { + if (a[i].timestamp <= b[j].timestamp) { + merged.push(a[i++]); + } else { + merged.push(b[j++]); + } + } + + return merged.concat(a.slice(i), b.slice(j)); +} diff --git a/src/lib/models/viewer.svelte.ts b/src/lib/models/viewer.svelte.ts index fa343b17..633461a0 100644 --- a/src/lib/models/viewer.svelte.ts +++ b/src/lib/models/viewer.svelte.ts @@ -1,8 +1,6 @@ import type { Channel } from "./channel.svelte"; import type { User } from "./user.svelte"; -import { MessageHistory } from "./message-history.svelte"; - export interface TimeoutOptions { /** * The duration of the timeout in seconds. @@ -16,8 +14,6 @@ export interface TimeoutOptions { } export class Viewer { - #history: MessageHistory | null = null; - public readonly id: string; public readonly username: string; public readonly displayName: string; @@ -98,13 +94,6 @@ export class Viewer { return this.monitored || this.restricted || this.possibleBanEvader; } - /** - * The messages the viewer has sent in the channel. - */ - public get history() { - return (this.#history ??= new MessageHistory(this)); - } - public ban(reason?: string) { return this.channel.viewers.ban(this.username, reason); } diff --git a/src/lib/settings/index.ts b/src/lib/settings/index.ts index a1df0edf..886ee12e 100644 --- a/src/lib/settings/index.ts +++ b/src/lib/settings/index.ts @@ -114,3 +114,7 @@ export const defaults: Settings = { export const settings = new RuneStore("settings", defaults, { autoStart: true, }); + +export function deletedAppearance(message: { deleted: boolean }) { + return message.deleted ? settings.state["moderation.deleted.appearance"] : undefined; +}