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/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..efae2117 --- /dev/null +++ b/src/lib/models/message-history.svelte.ts @@ -0,0 +1,152 @@ +import type { ResultOf } from "gql.tada"; + +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 { + 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 merge( + this.#logged.filter((message) => !ids.has(message.id)), + received, + ); + }); + + 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; + } +} + +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/whisper.svelte.ts b/src/lib/models/whisper.svelte.ts index 7eb21a77..78356d9b 100644 --- a/src/lib/models/whisper.svelte.ts +++ b/src/lib/models/whisper.svelte.ts @@ -5,12 +5,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 +23,9 @@ export interface WhisperMessage { text: string; } -interface MessagePage { - messages: WhisperMessage[]; - cursor: string | null; - hasNext: boolean; -} - export class Whisper { + readonly #pager = new Pager((after) => this.#fetchPage(after)); + // Live messages received while the history request is in flight. #pending: WhisperMessage[] = []; @@ -38,8 +34,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 +67,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 +77,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 +124,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); + public async loadOlder() { + const older = await this.#pager.older(); - // Pages can overlap if messages arrive between requests. - const known = new Set(this.messages.map((message) => message.id)); - - 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 +172,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 +194,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; + } +} 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; +}