diff --git a/package.json b/package.json index e07f01a5..7db962c1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "mode-watcher": "^1.1.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", + "tldts": "^7.0.11", "virtua": "^0.41.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a982be1a..3a0a2933 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: tailwindcss: specifier: ^4.1.11 version: 4.1.11 + tldts: + specifier: ^7.0.11 + version: 7.0.11 virtua: specifier: ^0.41.5 version: 0.41.5(svelte@5.36.4) @@ -2493,6 +2496,13 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.11: + resolution: {integrity: sha512-65eeOpBwWBabh0XqT+zB0vEllq/V3XcrF2fhgMXWWFfNw1yxEjeYg9Vv/B/UNozd0CTR/TohO1ubfn6O6mBW3w==} + + tldts@7.0.11: + resolution: {integrity: sha512-7k7JV/LZpGhFUu2t+YDaMZ1wdPPRNpaCYNQ0NQbSLY3Rbgy+XbCdkXyqRiS9TLXiYAsrv0yiA0OvnxmgRFCdNA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5173,6 +5183,12 @@ snapshots: tinyspy@3.0.2: optional: true + tldts-core@7.0.11: {} + + tldts@7.0.11: + dependencies: + tldts-core: 7.0.11 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/src/lib/components/Emote.svelte b/src/lib/components/Emote.svelte index 9d43f765..bccfb31a 100644 --- a/src/lib/components/Emote.svelte +++ b/src/lib/components/Emote.svelte @@ -4,10 +4,10 @@ interface Props { emote: Emote; - overlays?: Emote[]; + layers?: Emote[]; } - const { emote, overlays = [] }: Props = $props(); + const { emote, layers = [] }: Props = $props(); const srcset = emote.srcset.join(", "); @@ -20,11 +20,11 @@ decoding="async" /> - {#each overlays as overlay} + {#each layers as layer} {overlay.name} {/each} diff --git a/src/lib/components/message/Message.svelte b/src/lib/components/message/Message.svelte index d5694e42..691f0677 100644 --- a/src/lib/components/message/Message.svelte +++ b/src/lib/components/message/Message.svelte @@ -1,6 +1,6 @@ @@ -70,57 +69,59 @@ render properly without an extra space in between. --> class={["inline", message.isAction && "italic"]} style:color={message.isAction ? message.author.color : null} > - {#each fragments as fragment, i} - {#if fragment.type === "mention"} - {#if !message.reply || (message.reply && i > 0)} - - @{fragment.user?.displayName ?? fragment.fallback} - - {/if} - {:else if fragment.type === "url"} + {#each message.nodes as node, i} + {#if node.type === "link"} openUrl(fragment.url.toString())} + onclick={() => openUrl(node.data.url.toString())} > - {fragment.text} + {node.value} - {:else if fragment.type === "cheermote"} - {#if fragment.marked} - {fragment.prefix + fragment.bits} + {:else if node.type === "mention"} + {#if !message.reply || (message.reply && i > 0)} + + @{node.data.user?.displayName ?? node.value.slice(1)} + + {/if} + {:else if node.type === "cheer"} + {#if node.marked} + {node.data.prefix + node.data.bits} {:else} {fragment.prefix} {fragment.bits} - {fragment.bits} + {node.data.bits} {/if} - {:else if fragment.type === "emote"} - {#if fragment.marked} - {fragment.name} + {:else if node.type === "emote"} + {#if node.marked} + {node.data.emote.name} {:else} - + {/if} {:else} - - {fragment.value} + + {node.value} {/if} - {#if i < fragments.length - 1} + {#if i < message.nodes.length - 1} {" "} {/if} diff --git a/src/lib/handlers/eventsub/automod-message-hold.ts b/src/lib/handlers/eventsub/automod-message-hold.ts index 40f35bc1..96d09a35 100644 --- a/src/lib/handlers/eventsub/automod-message-hold.ts +++ b/src/lib/handlers/eventsub/automod-message-hold.ts @@ -38,7 +38,7 @@ export default defineHandler({ } } - message.addAutoModMetadata({ + message.setAutoMod({ category: reason, level: isAutoMod ? data.automod.level : Number.NaN, boundaries, diff --git a/src/lib/handlers/eventsub/channel-chat-user-message-hold.ts b/src/lib/handlers/eventsub/channel-chat-user-message-hold.ts index f399715b..df89fcef 100644 --- a/src/lib/handlers/eventsub/channel-chat-user-message-hold.ts +++ b/src/lib/handlers/eventsub/channel-chat-user-message-hold.ts @@ -12,7 +12,7 @@ export default defineHandler({ name: data.user_name, }); - message.addAutoModMetadata({ + message.setAutoMod({ category: "msg_hold", level: Number.NaN, boundaries: [], diff --git a/src/lib/message/index.ts b/src/lib/message/index.ts index f3757397..ab16c924 100644 --- a/src/lib/message/index.ts +++ b/src/lib/message/index.ts @@ -1,3 +1,4 @@ export * from "./message.svelte"; +export * from "./parse"; export * from "./system-message"; export * from "./user-message"; diff --git a/src/lib/message/parse.ts b/src/lib/message/parse.ts new file mode 100644 index 00000000..5ffb6ec9 --- /dev/null +++ b/src/lib/message/parse.ts @@ -0,0 +1,224 @@ +import { parse as parseTld } from "tldts"; +import { app } from "$lib/state.svelte"; +import type { Emote } from "$lib/tauri"; +import type { CheermoteTier } from "$lib/twitch/api"; +import type { Range } from "$lib/twitch/irc"; +import type { User } from "$lib/user.svelte"; +import { find } from "$lib/util"; +import type { UserMessage } from "./user-message"; + +interface BaseNode { + start: number; + end: number; + value: string; + marked: boolean; +} + +interface TextNode extends BaseNode { + type: "text"; + data: string; +} + +interface LinkNode extends BaseNode { + type: "link"; + data: { + url: URL; + }; +} + +interface MentionNode extends BaseNode { + type: "mention"; + data: { + user: User | undefined; + }; +} + +interface CheerNode extends BaseNode { + type: "cheer"; + data: { + prefix: string; + bits: number; + tier: CheermoteTier; + }; +} + +interface EmoteNode extends BaseNode { + type: "emote"; + data: { + layers: Emote[]; + emote: Emote; + }; +} + +export type Node = TextNode | LinkNode | MentionNode | CheerNode | EmoteNode; + +export function parse(message: UserMessage): Node[] { + const nodes: Node[] = []; + + const ircEmotes = [...message.data.emotes]; + const boundaries = translateBoundaries(message); + + for (const match of message.text.matchAll(/\S+|\s+/g)) { + const prevNode = nodes.at(-1); + let marked = false; + + const part = match[0]; + const start = match.index; + const end = start + part.length; + + for (const boundary of boundaries) { + if (end > boundary.start && start <= boundary.end) { + marked = true; + break; + } + } + + const base: BaseNode = { + start, + end, + value: part, + marked, + }; + + const url = URL.parse(`https://${part.replace(/^https?:\/\/|\.$/i, "")}`); + const result = url ? parseTld(url.hostname) : null; + + const cheermote = app.joined?.cheermotes.find((c) => { + const hasPrefix = part.toLowerCase().startsWith(c.prefix.toLowerCase()); + const hasBits = /\d+$/.test(part); + + return hasPrefix && hasBits; + }); + + const ircEmote = ircEmotes.find((e) => e.code === part); + const emote = app.joined?.emotes.get(part); + + if (url && result?.domain && result.isIcann) { + nodes.push({ + ...base, + type: "link", + data: { url }, + }); + } else if (/^@\w{4,24}$/.test(part)) { + const name = part.slice(1).toLowerCase(); + const user = find(app.joined?.viewers ?? [], (u) => u.username === name); + + nodes.push({ + ...base, + type: "mention", + data: { user }, + }); + } else if (cheermote) { + const amount = Number(part.slice(cheermote.prefix.length)); + + if (amount > 0) { + let selectedTier: CheermoteTier | undefined; + + for (const tier of cheermote.tiers.sort((a, b) => b.min_bits - a.min_bits)) { + if (amount >= tier.min_bits) { + selectedTier = tier; + break; + } + } + + if (selectedTier) { + nodes.push({ + ...base, + type: "cheer", + data: { + prefix: cheermote.prefix, + bits: amount, + tier: selectedTier, + }, + }); + } + } + } else if (ircEmote) { + for (const boundary of boundaries) { + if (ircEmote.range.end > boundary.start && ircEmote.range.start <= boundary.end) { + marked = true; + break; + } + } + + const baseUrl = "https://static-cdn.jtvnw.net/emoticons/v2"; + + nodes.push({ + start: ircEmote.range.start, + end: ircEmote.range.end, + value: ircEmote.code, + marked, + type: "emote", + data: { + emote: { + name: ircEmote.code, + width: 56, + height: 56, + srcset: [1, 2, 3].map((density) => { + return `${baseUrl}/${ircEmote.id}/default/dark/${density}.0 ${density}x`; + }), + zero_width: false, + }, + layers: [], + }, + }); + + const foundIdx = ircEmotes.indexOf(ircEmote); + ircEmotes.splice(foundIdx, 1); + } else if (emote) { + if (emote.zero_width && prevNode?.type === "emote") { + prevNode.data.layers.push(emote); + } else { + nodes.push({ + ...base, + type: "emote", + data: { + emote, + layers: [], + }, + }); + } + } else { + nodes.push({ + ...base, + type: "text", + data: part, + }); + } + } + + const merged: Node[] = []; + + for (const node of nodes) { + const prevNode = merged.at(-1); + + if (node.type === "text" && prevNode?.type === "text" && node.marked === prevNode.marked) { + prevNode.end = node.end; + prevNode.value += node.value; + prevNode.data += node.data; + } else { + merged.push(node); + } + } + + return merged; +} + +function translateBoundaries(message: UserMessage): Range[] { + if (!message.autoMod?.boundaries) return []; + + const map = [0]; + let index = 0; + + const segmenter = new Intl.Segmenter(navigator.language, { granularity: "grapheme" }); + + for (const data of segmenter.segment(message.text)) { + index += data.segment.length; + map.push(index); + } + + return message.autoMod.boundaries.map((b) => ({ + start: map[b.start_pos], + end: map[b.end_pos], + })); +} diff --git a/src/lib/message/user-message.ts b/src/lib/message/user-message.ts index 451c2875..4d730b2d 100644 --- a/src/lib/message/user-message.ts +++ b/src/lib/message/user-message.ts @@ -1,26 +1,10 @@ import { app } from "$lib/state.svelte"; -import type { Emote } from "$lib/tauri"; -import type { CheermoteTier } from "$lib/twitch/api"; import type { AutoModMetadata, StructuredMessage } from "$lib/twitch/eventsub"; -import type { Badge, BasicUser, PrivmsgMessage, Range, UserNoticeMessage } from "$lib/twitch/irc"; +import type { Badge, BasicUser, PrivmsgMessage, UserNoticeMessage } from "$lib/twitch/irc"; import { User } from "$lib/user.svelte"; -import { extractEmotes, find } from "$lib/util"; -import { Message } from "."; - -export type Fragment = - | { type: "text"; value: string; marked?: boolean } - | { type: "mention"; user?: User; fallback: string; marked?: boolean } - | { type: "url"; text: string; url: URL; marked?: boolean } - | ({ type: "emote"; marked?: boolean; overlays: Emote[] } & Emote) - | ({ type: "cheermote"; prefix: string; bits: number; marked?: boolean } & CheermoteTier); - -const URL_RE = - /(?<=\s)https?:\/\/(?:www\.)?[-\w@:%.+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-\w()@:%+.~#?&/=]*/g; - -interface TextSegment extends Range { - type: "emote" | "cheermote" | "mention" | "url"; - data: Record; -} +import { extractEmotes } from "$lib/util"; +import { Message, parse } from "./"; +import type { Node } from "./"; /** * User messages are either messages received by `PRIVMSG` commands or @@ -29,6 +13,7 @@ interface TextSegment extends Range { export class UserMessage extends Message { #author: User; #autoMod: AutoModMetadata | null = null; + #nodes: Node[] = []; public constructor(public readonly data: PrivmsgMessage | UserNoticeMessage) { super(data); @@ -106,6 +91,13 @@ export class UserMessage extends Message { ); } + /** + * The user who sent the message. + */ + public get author() { + return this.#author; + } + /** * The AutoMod metadata attached to the message if it was caught by AutoMod. */ @@ -155,6 +147,14 @@ export class UserMessage extends Message { return "event" in this.data ? this.data.event : null; } + public get nodes() { + if (!this.#nodes.length) { + this.#nodes = parse(this).sort((a, b) => a.start - b.start); + } + + return this.#nodes; + } + /** * The metadata for the parent and thread starter messages if the message * is a reply. @@ -163,284 +163,8 @@ export class UserMessage extends Message { return "reply" in this.data ? this.data.reply : null; } - /** - * The user who sent the message. - */ - public get author() { - return this.#author; - } - - public addAutoModMetadata(metadata: AutoModMetadata) { + public setAutoMod(metadata: AutoModMetadata) { this.#autoMod = metadata; return this; } - - public toFragments() { - const output: Fragment[] = []; - - const text = this.text; - if (!text) return output; - - const chars = Array.from(text); - const isCharMarked = Array.from({ length: chars.length }).fill(false); - - for (const boundary of this.#autoMod?.boundaries ?? []) { - for (let i = boundary.start_pos; i < boundary.end_pos && i < chars.length; i++) { - isCharMarked[i] = true; - } - } - - const segments: TextSegment[] = []; - - if (this.bits > 0) { - for (const cheermote of app.joined?.cheermotes ?? []) { - const cheermoteRe = new RegExp(`\\b(${cheermote.prefix})(\\d+)\\b`, "gi"); - let match: RegExpExecArray | null; - - // eslint-disable-next-line no-cond-assign - while ((match = cheermoteRe.exec(text))) { - const bits = Number(match[2]); - - let selectedTier: CheermoteTier | undefined; - - for (const tier of cheermote.tiers.sort((a, b) => b.min_bits - a.min_bits)) { - if (bits >= tier.min_bits) { - selectedTier = tier; - break; - } - } - - if (!selectedTier) continue; - - segments.push({ - type: "cheermote", - start: match.index, - end: match.index + match[0].length, - data: { - prefix: match[1], - bits: match[2], - tier: selectedTier, - }, - }); - } - } - } - - for (const emote of this.data.emotes) { - segments.push({ - type: "emote", - start: emote.range.start, - end: emote.range.end, - data: { id: emote.id, code: emote.code }, - }); - } - - const mentionRe = /(?<=\s)@(\w+)/g; - let match: RegExpExecArray | null; - - // eslint-disable-next-line no-cond-assign - while ((match = mentionRe.exec(text))) { - segments.push({ - type: "mention", - start: match.index, - end: match.index + match[0].length, - data: { token: match[0], username: match[1] }, - }); - } - - URL_RE.lastIndex = 0; - - // eslint-disable-next-line no-cond-assign - while ((match = URL_RE.exec(text))) { - if (!URL.canParse(match[0])) continue; - - segments.push({ - type: "url", - start: match.index, - end: match.index + match[0].length, - data: { text: match[0] }, - }); - } - - segments.sort((a, b) => a.start - b.start); - - const processed: TextSegment[] = []; - let lastEnd = -1; - - for (const segment of segments) { - if (segment.start >= lastEnd) { - processed.push(segment); - lastEnd = segment.end; - } - } - - let currentIndex = 0; - - for (const segment of processed) { - if (segment.start > currentIndex) { - const chunk = chars.slice(currentIndex, segment.start).join(""); - this.#processChunk(chunk, currentIndex, isCharMarked, output); - } - - const marked = this.#isRangeMarked(segment.start, segment.end, isCharMarked); - - if (segment.type === "cheermote") { - output.push({ - type: "cheermote", - prefix: segment.data.prefix, - bits: segment.data.bits, - marked, - ...segment.data.tier, - }); - } else if (segment.type === "emote") { - const baseUrl = "https://static-cdn.jtvnw.net/emoticons/v2"; - - output.push({ - type: "emote", - name: segment.data.code, - width: 56, - height: 56, - srcset: [1, 2, 3].map( - (density) => - `${baseUrl}/${segment.data.id}/default/dark/${density}.0 ${density}x`, - ), - zero_width: false, - marked, - overlays: [], - }); - } else if (segment.type === "mention") { - const mention = segment.data.username; - - const user = app.joined - ? find(app.joined.viewers, (user) => user.username === mention.toLowerCase()) - : undefined; - - output.push({ - type: "mention", - user, - fallback: mention, - marked, - }); - } else if (segment.type === "url") { - output.push({ - type: "url", - text: segment.data.text, - url: new URL(segment.data.text), - marked, - }); - } - - currentIndex = segment.end; - } - - if (currentIndex < text.length) { - const chunk = chars.slice(currentIndex).join(""); - this.#processChunk(chunk, currentIndex, isCharMarked, output); - } - - const fragments: Fragment[] = []; - let lastFrag: Fragment | null = null; - - for (const frag of output) { - if (frag.type === "text") { - if (lastFrag && lastFrag.marked === frag.marked) { - lastFrag.value += frag.value; - } else { - if (lastFrag) fragments.push(lastFrag); - lastFrag = { ...frag }; - } - } else { - if (lastFrag) { - fragments.push(lastFrag); - lastFrag = null; - } - - fragments.push(frag); - } - } - - if (lastFrag) fragments.push(lastFrag); - - return fragments; - } - - #isRangeMarked(start: number, end: number, isCharMarked: boolean[]): boolean { - if (start >= end) return false; - for (let i = start; i < end; i++) { - if (isCharMarked[i]) return true; - } - - return false; - } - - #processChunk( - chunk: string, - offset: number, - isCharMarked: boolean[], - fragments: Fragment[], - ): void { - if (!chunk) return; - - let buffer: string[] = []; - let wordStart = 0; - - const flush = (joiner = " ") => { - if (buffer.length) { - const text = buffer.join(joiner); - - const start = offset + wordStart; - const end = start + Array.from(text).length; - - const marked = this.#isRangeMarked(start, end, isCharMarked); - - fragments.push({ type: "text", value: text, marked }); - buffer = []; - } - }; - - let chunkPos = 0; - let lastFrag: Extract | null = null; - - for (const token of chunk.split(/(\s+)/)) { - if (/\s+/.test(token)) { - flush(""); - - const start = offset + chunkPos; - const end = start + token.length; - - const marked = this.#isRangeMarked(start, end, isCharMarked); - - fragments.push({ type: "text", value: token, marked }); - } else if (token) { - const emote = app.joined?.emotes.get(token); - - if (emote) { - flush(); - - const start = offset + chunkPos; - const end = start + token.length; - - const marked = this.#isRangeMarked(start, end, isCharMarked); - - if (emote.zero_width && lastFrag) { - lastFrag.overlays.push(emote); - } else { - lastFrag = { type: "emote", ...emote, marked, overlays: [] }; - fragments.push(lastFrag); - } - } else { - if (buffer.length === 0) { - wordStart = chunkPos; - } - - buffer.push(token); - flush(); - } - } - - chunkPos += Array.from(token).length; - } - - flush(); - } }