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

threadLinks.ts

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