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}
{/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.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();
- }
}