Skip to content
Source
Sign inSign up
Open History to see the last commit

threadLinks.ts

223 lines9.1 KB

1import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4​
5import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts";
6​
7export const THREAD_LINK_PROTOCOL = "t3-thread";
8const V1_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`;
9const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`;
10const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`;
11const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+";
12const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`;
13// Code comes first in the alternation so a link written inside a code span or fence is skipped.
14// A labeled link's destination may be angle-bracketed, as CommonMark allows: [label](<href>).
15const THREAD_LINK_OUTSIDE_CODE = new RegExp(
16 String.raw`(?<fence>(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?<span>(\`+)[^\n]*?\4)|\[(?<label>[^\]\n]*)\]\((?:<(?<angleHref>t3-thread:\/\/v[12]\/[^\s<>]+|${HOSTED_LINK})>|(?<href>t3-thread:\/\/v[12]\/[^\s>)]+|${HOSTED_LINK}))\)|<(?<autolink>t3-thread:\/\/v[12]\/[^\s<>]+|${HOSTED_LINK})>|(?<bare>${HOSTED_LINK})(?![\w%/?#-])`,
17 "g",
18);
19​
20const decodeThreadId = Schema.decodeUnknownOption(ThreadId);
21const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId);
22​
23/** A v1 link names no environment; the message's own, or a unique match, stands in. */
24export interface ThreadLink {
25 readonly environmentId: EnvironmentId | null;
26 readonly threadId: ThreadId;
28​
29// Escapes even URL-safe punctuation, so a sentence's closing period never reads as part of an id.
30const hostedSegment = (id: string) =>
31 encodeURIComponent(id).replace(
32 /[.!~*'()]/g,
33 (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
34 );
35​
36/** Opens the thread from anywhere: the hosted app offers the desktop app, the mobile app, or itself. */
37export function hostedThreadLink(ref: ScopedThreadRef, origin = DEFAULT_HOSTED_APP_URL): string {
38 const path = `/threads/${hostedSegment(ref.environmentId)}/${hostedSegment(ref.threadId)}`;
39 return new URL(path, origin).toString();
41​
42const HOSTED_PATH = new RegExp(`^(${HOSTED_SEGMENT})/(${HOSTED_SEGMENT})$`);
43​
44function parseHostedThreadLink(href: string): ThreadLink | null {
45 const match = HOSTED_PATH.exec(href.slice(HOSTED_PREFIX.length));
46 if (!match) return null;
47 try {
48 const environmentId = Option.getOrNull(decodeEnvironmentId(decodeURIComponent(match[1]!)));
49 const threadId = Option.getOrNull(decodeThreadId(decodeURIComponent(match[2]!)));
50 return environmentId === null || threadId === null ? null : { environmentId, threadId };
51 } catch {
52 return null;
53 }
55​
56/**
57 * The ids as written. Thread ids can hold percent escapes of their own, so they are not decoded,
58 * except in a hosted link, which escapes them itself.
59 */
60export function parseThreadLinkHref(href: string): ThreadLink | null {
61 if (href.startsWith(HOSTED_PREFIX)) return parseHostedThreadLink(href);
62 if (href.startsWith(V1_PREFIX)) {
63 const threadId = Option.getOrNull(decodeThreadId(href.slice(V1_PREFIX.length)));
64 return threadId === null ? null : { environmentId: null, threadId };
65 }
66 if (!href.startsWith(V2_PREFIX)) return null;
67 const path = href.slice(V2_PREFIX.length);
68 const slash = path.indexOf("/");
69 if (slash < 0) return null;
70 const environmentId = Option.getOrNull(decodeEnvironmentId(path.slice(0, slash)));
71 const threadId = Option.getOrNull(decodeThreadId(path.slice(slash + 1)));
72 return environmentId === null || threadId === null ? null : { environmentId, threadId };
74​
75function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null {
76 try {
77 const decoded = decodeURIComponent(threadId);
78 return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded));
79 } catch {
80 return null;
81 }
83​
84/** The threads a link can name, in its own environment or else the message's: as written, then decoded. */
85export function threadLinkCandidates(
86 link: ThreadLink,
87 messageEnvironmentId: EnvironmentId,
88): ReadonlyArray<ScopedThreadRef> {
89 const environmentId = link.environmentId ?? messageEnvironmentId;
90 const decodedId = percentDecodedThreadLinkId(link.threadId);
91 const written = { environmentId, threadId: link.threadId };
92 return decodedId === null ? [written] : [written, { environmentId, threadId: decodedId }];
94​
95/** A v1 link its message's environment lacks opens a unique match elsewhere; a v2 link never does. */
96export function guessThreadLinkRef(
97 link: ThreadLink,
98 refs: ReadonlyArray<ScopedThreadRef>,
99): ScopedThreadRef | null {
100 if (link.environmentId !== null) return null;
101 const decodedId = percentDecodedThreadLinkId(link.threadId);
102 for (const threadId of decodedId === null ? [link.threadId] : [link.threadId, decodedId]) {
103 let match: ScopedThreadRef | null = null;
104 for (const ref of refs) {
105 if (ref.threadId !== threadId) continue;
106 if (match !== null) return null;
107 match = ref;
108 }
109 if (match !== null) return match;
110 }
111 return null;
113​
114function threadLinkHref(link: ThreadLink): string {
115 return link.environmentId === null
116 ? `${V1_PREFIX}${link.threadId}`
117 : `${V2_PREFIX}${link.environmentId}/${link.threadId}`;
119​
120const linkLabel = (label: string | undefined) =>
121 label
122 ?.replace(/[[\]\\\r\n]/g, " ")
123 .replace(/\s+/g, " ")
124 .trim();
125​
126/** Without a label, an autolink: clients show the thread's current title. */
127export function formatThreadLink(ref: ThreadLink, label?: string): string {
128 const cleaned = linkLabel(label);
129 const href = threadLinkHref(ref);
130 const destination = /[()]/.test(href) ? `<${href}>` : href;
131 return cleaned ? `[${cleaned}](${destination})` : `<${href}>`;
133​
134/** What a person copies out of T3 Code: a Markdown link that opens the thread anywhere. */
135export function formatHostedThreadLink(
136 ref: ScopedThreadRef,
137 label: string,
138 origin?: string,
139): string {
140 const href = hostedThreadLink(ref, origin);
141 const cleaned = linkLabel(label);
142 return cleaned ? `[${cleaned}](${href})` : href;
144​
145export function hasThreadLinks(markdown: string): boolean {
146 return markdown.includes(`${THREAD_LINK_PROTOCOL}://v`) || markdown.includes(HOSTED_PREFIX);
148​
149/** Every thread the markdown's links can name, so their titles are keyed reads. */
150export function threadLinkRefs(
151 markdown: string,
152 messageEnvironmentId: EnvironmentId,
153): ReadonlyArray<ScopedThreadRef> {
154 const refs = new Map<string, ScopedThreadRef>();
155 // Hermes' matchAll yields matches without `groups`; exec keeps them.
156 const pattern = new RegExp(THREAD_LINK_OUTSIDE_CODE);
157 for (let match = pattern.exec(markdown); match !== null; match = pattern.exec(markdown)) {
158 const href =
159 match.groups?.angleHref ?? match.groups?.href ?? match.groups?.autolink ?? match.groups?.bare;
160 const link = href === undefined ? null : parseThreadLinkHref(href);
161 if (link === null) continue;
162 for (const ref of threadLinkCandidates(link, messageEnvironmentId)) {
163 refs.set(`${ref.environmentId}/${ref.threadId}`, ref);
164 }
165 }
166 return [...refs.values()];
168​
169export function unknownThreadLinkLabel(
170 link: ThreadLink,
171 messageEnvironmentId: EnvironmentId,
172): string {
173 return link.environmentId === null || link.environmentId === messageEnvironmentId
174 ? "Thread no longer available"
175 : "Thread on another machine";
177​
178/** Rewrites each link outside code `replace` answers for. `label` is a Markdown link's own text. */
179export function replaceThreadLinks(
180 markdown: string,
181 replace: (link: ThreadLink, label: string | undefined) => string | undefined,
182): string {
183 if (!hasThreadLinks(markdown)) return markdown;
184 return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => {
185 const groups = args.at(-1) as Partial<
186 Record<"angleHref" | "href" | "autolink" | "bare" | "label", string>
187 >;
188 const href = groups.angleHref ?? groups.href ?? groups.autolink ?? groups.bare;
189 const link = href === undefined ? null : parseThreadLinkHref(href);
190 return (link && replace(link, groups.label)) ?? source;
191 });
193​
194/**
195 * Writes each link with its thread's current title, a hosted link as a v2 link. A link `title`
196 * cannot resolve becomes plain text, its written label or a neutral one, except a labeled v1
197 * link, which may open elsewhere.
198 */
199export function relabelThreadLinks(
200 markdown: string,
201 messageEnvironmentId: EnvironmentId,
202 title: (ref: ScopedThreadRef) => string | undefined,
203): string {
204 return replaceThreadLinks(markdown, (written, label) => {
205 const environmentId = written.environmentId ?? messageEnvironmentId;
206 const decoded = percentDecodedThreadLinkId(written.threadId);
207 const threadId =
208 title({ environmentId, threadId: written.threadId }) === undefined &&
209 decoded !== null &&
210 title({ environmentId, threadId: decoded }) !== undefined
211 ? decoded
212 : written.threadId;
213 const current = title({ environmentId, threadId });
214 if (current === undefined) {
215 if (label !== undefined && written.environmentId === null) return undefined;
216 return label?.trim() || unknownThreadLinkLabel(written, messageEnvironmentId);
217 }
218 return formatThreadLink(
219 { environmentId: written.environmentId, threadId },
220 current.trim() || label || threadId,
221 );
222 });