@gryt/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @gryt/core
2
+
3
+ Gryt's shared application logic. One implementation of the things the desktop
4
+ app and the phone app both do.
5
+
6
+ ## What goes in here
7
+
8
+ Two questions, and a module needs both answers to be yes:
9
+
10
+ - Would it compile with no DOM and no React Native?
11
+ - Would both apps otherwise need a copy?
12
+
13
+ `tsconfig.json` enforces the first by leaving `DOM` out of `lib`, so `document`,
14
+ `window` and `localStorage` fail to typecheck rather than working in one app and
15
+ breaking in the other. The second is a judgement call, and the surface guard is
16
+ what stops it drifting: `scripts/check-public-surface.mjs` fails when an export
17
+ is missing *and* when one appears that nobody listed.
18
+
19
+ ## What does not
20
+
21
+ Anything that needs a platform. Fetching is the clearest case — the desktop and
22
+ the phone reach a server differently, and neither way belongs here — so this
23
+ package decides what a link preview *means* and each app fetches it.
24
+
25
+ Artwork is the other one. A brand logo is a React component on the desktop and
26
+ an SVG path or a favicon on the phone. This package owns the hostnames, the
27
+ colours and the path rules; each app maps a provider `id` to its own icon.
28
+
29
+ `@gryt/voice` splits web and native behind two entry points because a media
30
+ engine genuinely cannot avoid the platform. This package has no such entry on
31
+ purpose. If one becomes necessary, that is a decision to make out loud.
32
+
33
+ ## What is in it now
34
+
35
+ **Reports** — the shape `Gryt-chat/reports` takes, and how an app fills it in.
36
+ The two apps had a copy each with the same seven exports and different insides.
37
+ Most of the difference was real: a renderer knows its Chrome build, a phone
38
+ knows whether it is a simulator. Some of it was not — `MESSAGE_MAX` was 8000 on
39
+ the desktop and 4000 on the phone, and nothing recorded a reason.
40
+
41
+ **Links** — which site a URL belongs to, what colour to draw its card, what line
42
+ to read out of its path, and which of four shapes a preview earns. 73 sites,
43
+ seven of which also read a detail from the path.
44
+
45
+ ## Working on it
46
+
47
+ ```bash
48
+ npm install
49
+ npm test # node --test, no runner to install
50
+ npm run typecheck
51
+ npm run build # tsc to dist/, then rewrite specifiers for ESM
52
+ npm run check-surface
53
+ ```
54
+
55
+ `prepublishOnly` runs the build, the tests and the surface guard, so a release
56
+ that would have shipped a missing export fails before it leaves.
57
+
58
+ ## Versioning
59
+
60
+ Both apps pin this package. They should pin the *same* version: the point is one
61
+ implementation, and two apps on different versions is two implementations with
62
+ extra steps. `@gryt/voice` is currently `0.4.4` in the client and `^0.3.2` in
63
+ mobile, which is the failure this is meant to avoid.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Gryt's shared application logic.
3
+ *
4
+ * One implementation of the things the desktop and the phone both do. What
5
+ * lives here is decided by a single test: could this file compile with no DOM
6
+ * and no React Native, and would the two apps otherwise both need a copy? If
7
+ * either answer is no, it belongs in the app.
8
+ *
9
+ * The barrel is flat and named rather than a set of subpath exports, because
10
+ * the two apps import from it by name and `check-public-surface.mjs` asserts
11
+ * that everything they import is actually here. Subpaths would need that guard
12
+ * per path for no gain at this size.
13
+ */
14
+ export { buildReport, describeAttached, MESSAGE_MAX, TITLE_MAX, type Diagnostics, type Report, type ReportType, } from "./reports/report.js";
15
+ export { getAccentColor, getLinkProvider, getProviderDetail, hostnameOf, LINK_PROVIDERS, type LinkProvider, } from "./links/providers.js";
16
+ export { describePreviewFailure, extractUrls, getCardSubtitle, getLinkCardLayout, type LinkCardLayout, type LinkPreviewData, } from "./links/preview.js";
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Gryt's shared application logic.
3
+ *
4
+ * One implementation of the things the desktop and the phone both do. What
5
+ * lives here is decided by a single test: could this file compile with no DOM
6
+ * and no React Native, and would the two apps otherwise both need a copy? If
7
+ * either answer is no, it belongs in the app.
8
+ *
9
+ * The barrel is flat and named rather than a set of subpath exports, because
10
+ * the two apps import from it by name and `check-public-surface.mjs` asserts
11
+ * that everything they import is actually here. Subpaths would need that guard
12
+ * per path for no gain at this size.
13
+ */
14
+ export { buildReport, describeAttached, MESSAGE_MAX, TITLE_MAX, } from "./reports/report.js";
15
+ export { getAccentColor, getLinkProvider, getProviderDetail, hostnameOf, LINK_PROVIDERS, } from "./links/providers.js";
16
+ export { describePreviewFailure, extractUrls, getCardSubtitle, getLinkCardLayout, } from "./links/preview.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What the server found out about a link, and what a card should do with it.
3
+ *
4
+ * The data comes from `GET /api/link-preview`; the shape is the server's, and
5
+ * the decisions below are the ones both apps were making separately. Fetching
6
+ * is not here — the desktop and the phone reach a server differently and neither
7
+ * way belongs in a package with no platform.
8
+ */
9
+ export interface LinkPreviewData {
10
+ url: string;
11
+ title: string | null;
12
+ description: string | null;
13
+ image: string | null;
14
+ imageWidth: number | null;
15
+ imageHeight: number | null;
16
+ siteName: string | null;
17
+ favicon: string | null;
18
+ imageAlt?: string | null;
19
+ /** The colour the page declares for itself, used when we know no brand. */
20
+ themeColor?: string | null;
21
+ /** `og:type`: "article", "video.other", "music.song". */
22
+ type?: string | null;
23
+ author?: string | null;
24
+ publishedAt?: string | null;
25
+ /** Present when the page advertises a real player. */
26
+ oembedUrl?: string | null;
27
+ /** What the page answered with, so a 404 can say so rather than guess. */
28
+ status?: number | null;
29
+ }
30
+ /**
31
+ * The links in a message, minus the ones that are already something else.
32
+ *
33
+ * Code spans and fences are stripped first: a URL inside backticks is being
34
+ * quoted rather than shared, and a card under it would be noise. Markdown
35
+ * images are stripped because the picture is already drawn.
36
+ */
37
+ export declare function extractUrls(text: string | null): string[];
38
+ /**
39
+ * How much of a card a preview can fill.
40
+ *
41
+ * A wide image wants to sit under the text at full width. A small or square one
42
+ * wants to be a thumbnail beside it. No image wants no space set aside for a
43
+ * picture at all. Drawing all three the same way is what produced a hostname
44
+ * next to an empty grey rectangle.
45
+ */
46
+ export type LinkCardLayout = "large" | "thumbnail" | "text" | "bare";
47
+ export declare function getLinkCardLayout(data: LinkPreviewData): LinkCardLayout;
48
+ /**
49
+ * Why a page gave us nothing, in words worth showing somebody.
50
+ *
51
+ * Only for statuses that mean something to a reader. A 500 is the site's
52
+ * problem and saying so helps nobody, so it returns null and the card falls
53
+ * back to showing the link on its own.
54
+ */
55
+ export declare function describePreviewFailure(status: number | null | undefined): string | null;
56
+ /**
57
+ * The line under the title, where the path says something the title does not.
58
+ *
59
+ * Wikipedia titles its WebRTC page "WebRTC" and the detail read out of
60
+ * `/wiki/WebRTC` is "WebRTC", so showing both printed the word twice.
61
+ */
62
+ export declare function getCardSubtitle(title: string | null, detail: string | null): string | null;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What the server found out about a link, and what a card should do with it.
3
+ *
4
+ * The data comes from `GET /api/link-preview`; the shape is the server's, and
5
+ * the decisions below are the ones both apps were making separately. Fetching
6
+ * is not here — the desktop and the phone reach a server differently and neither
7
+ * way belongs in a package with no platform.
8
+ */
9
+ const URL_REGEX = /https?:\/\/[^\s<>[\](){}'"`,]+[^\s<>[\](){}'"`,.:;!?)]/gi;
10
+ /**
11
+ * The links in a message, minus the ones that are already something else.
12
+ *
13
+ * Code spans and fences are stripped first: a URL inside backticks is being
14
+ * quoted rather than shared, and a card under it would be noise. Markdown
15
+ * images are stripped because the picture is already drawn.
16
+ */
17
+ export function extractUrls(text) {
18
+ if (!text)
19
+ return [];
20
+ let cleaned = text.replace(/```[\s\S]*?```/g, "");
21
+ cleaned = cleaned.replace(/`[^`]+`/g, "");
22
+ cleaned = cleaned.replace(/!\[[^\]]*\]\([^)]+\)/g, "");
23
+ const matches = cleaned.match(URL_REGEX);
24
+ if (!matches)
25
+ return [];
26
+ return [...new Set(matches)];
27
+ }
28
+ /** Wide enough, and landscape enough, to be a header rather than a thumbnail. */
29
+ const LARGE_IMAGE_MIN_WIDTH = 400;
30
+ const LARGE_IMAGE_MIN_ASPECT = 1.2;
31
+ export function getLinkCardLayout(data) {
32
+ const hasText = Boolean(data.title || data.description);
33
+ if (!data.image)
34
+ return hasText ? "text" : "bare";
35
+ const w = data.imageWidth;
36
+ const h = data.imageHeight;
37
+ /* Unknown dimensions count as large. A site that sets og:image and says
38
+ nothing about its size has almost always set a share card, and the ones
39
+ that have not lose less by being drawn big than a real share card loses by
40
+ being shrunk into a corner. */
41
+ if (!w || !h)
42
+ return "large";
43
+ if (w >= LARGE_IMAGE_MIN_WIDTH && w / h >= LARGE_IMAGE_MIN_ASPECT)
44
+ return "large";
45
+ return hasText ? "thumbnail" : "large";
46
+ }
47
+ /**
48
+ * Why a page gave us nothing, in words worth showing somebody.
49
+ *
50
+ * Only for statuses that mean something to a reader. A 500 is the site's
51
+ * problem and saying so helps nobody, so it returns null and the card falls
52
+ * back to showing the link on its own.
53
+ */
54
+ export function describePreviewFailure(status) {
55
+ if (status == null)
56
+ return null;
57
+ if (status === 401)
58
+ return "Sign-in only";
59
+ /* Not "private": a 403 is as often a site refusing our fetcher as it is a
60
+ page somebody is not allowed to see. Stack Overflow answers 403 to the
61
+ preview fetch and 200 to a browser. A private GitHub repository answers
62
+ 404 and is covered below. */
63
+ if (status === 403)
64
+ return "The site would not let us look";
65
+ if (status === 404 || status === 410)
66
+ return "Page not found";
67
+ if (status === 429)
68
+ return "The site is rate limiting us";
69
+ return null;
70
+ }
71
+ /**
72
+ * The line under the title, where the path says something the title does not.
73
+ *
74
+ * Wikipedia titles its WebRTC page "WebRTC" and the detail read out of
75
+ * `/wiki/WebRTC` is "WebRTC", so showing both printed the word twice.
76
+ */
77
+ export function getCardSubtitle(title, detail) {
78
+ if (!detail || !title)
79
+ return null;
80
+ return title.toLowerCase().includes(detail.toLowerCase()) ? null : detail;
81
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Which site a link belongs to, worked out from the URL alone.
3
+ *
4
+ * Knowing means a card can carry a site's identity before its metadata
5
+ * arrives, and can still carry it when the metadata never does — a private
6
+ * repository, a page behind Cloudflare, a site that blocks whatever fetches
7
+ * previews. Those otherwise draw a bare hostname.
8
+ *
9
+ * This is deliberately not the list of sites that "work". Any site with
10
+ * OpenGraph tags gets a full card without appearing here, and one that declares
11
+ * a `theme-color` gets a coloured one. Being on this list buys a real logo
12
+ * instead of a favicon, a brand colour that does not depend on the site
13
+ * declaring one, and for a few of them a line read straight out of the path.
14
+ *
15
+ * The logos are not here. A brand icon is a React component on the desktop and
16
+ * an SVG path or a favicon on the phone, and neither belongs in a package that
17
+ * must compile without a DOM. Each app maps `id` to its own artwork; this file
18
+ * owns the hostnames, the colours and the path rules, which is the part that
19
+ * was drifting.
20
+ */
21
+ export interface LinkProvider {
22
+ id: string;
23
+ label: string;
24
+ brand: string;
25
+ /** For brands that are essentially black and vanish on a dark card. */
26
+ brandDark?: string;
27
+ hosts: string[];
28
+ hostSuffixes?: string[];
29
+ detail?: (url: URL) => string | null;
30
+ }
31
+ /** Seventy-three sites, and the seven that also read a line out of the path. */
32
+ export declare const LINK_PROVIDERS: LinkProvider[];
33
+ /** The hostname without `www.`, or the input when that was not a URL. */
34
+ export declare function hostnameOf(url: string): string;
35
+ export declare function getLinkProvider(url: string): LinkProvider | null;
36
+ /** The line a provider can read out of the path, if it knows how. */
37
+ export declare function getProviderDetail(url: string): string | null;
38
+ /**
39
+ * The accent a card is drawn with: the brand where we know it, the colour the
40
+ * page declared where we do not, and null for the app's own accent when
41
+ * neither is on offer.
42
+ */
43
+ export declare function getAccentColor(url: string, themeColor: string | null | undefined, appearance: "light" | "dark"): string | null;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Which site a link belongs to, worked out from the URL alone.
3
+ *
4
+ * Knowing means a card can carry a site's identity before its metadata
5
+ * arrives, and can still carry it when the metadata never does — a private
6
+ * repository, a page behind Cloudflare, a site that blocks whatever fetches
7
+ * previews. Those otherwise draw a bare hostname.
8
+ *
9
+ * This is deliberately not the list of sites that "work". Any site with
10
+ * OpenGraph tags gets a full card without appearing here, and one that declares
11
+ * a `theme-color` gets a coloured one. Being on this list buys a real logo
12
+ * instead of a favicon, a brand colour that does not depend on the site
13
+ * declaring one, and for a few of them a line read straight out of the path.
14
+ *
15
+ * The logos are not here. A brand icon is a React component on the desktop and
16
+ * an SVG path or a favicon on the phone, and neither belongs in a package that
17
+ * must compile without a DOM. Each app maps `id` to its own artwork; this file
18
+ * owns the hostnames, the colours and the path rules, which is the part that
19
+ * was drifting.
20
+ */
21
+ const seg = (url) => url.pathname.split("/").filter(Boolean);
22
+ function githubDetail(url) {
23
+ const p = seg(url);
24
+ if (p.length === 0)
25
+ return null;
26
+ if (p.length === 1)
27
+ return `@${p[0]}`;
28
+ const repo = `${p[0]}/${p[1]}`;
29
+ const kind = p[2];
30
+ const number = p[3];
31
+ if (kind === "pull" && number)
32
+ return `${repo} · pull request #${number}`;
33
+ if (kind === "issues" && number)
34
+ return `${repo} · issue #${number}`;
35
+ if (kind === "releases")
36
+ return `${repo} · releases`;
37
+ if (kind === "commit" && number)
38
+ return `${repo} · commit ${number.slice(0, 7)}`;
39
+ return repo;
40
+ }
41
+ function modrinthDetail(url) {
42
+ const p = seg(url);
43
+ const kinds = {
44
+ mod: "Mod", plugin: "Plugin", datapack: "Data pack",
45
+ shader: "Shader", resourcepack: "Resource pack", modpack: "Modpack",
46
+ };
47
+ if (p.length >= 2 && kinds[p[0]])
48
+ return `${kinds[p[0]]} · ${p[1]}`;
49
+ if (p[0] === "user" && p[1])
50
+ return `@${p[1]}`;
51
+ return null;
52
+ }
53
+ function redditDetail(url) {
54
+ const p = seg(url);
55
+ if (p[0] === "r" && p[1])
56
+ return p[2] === "comments" ? `r/${p[1]} · post` : `r/${p[1]}`;
57
+ if ((p[0] === "u" || p[0] === "user") && p[1])
58
+ return `u/${p[1]}`;
59
+ return null;
60
+ }
61
+ function npmDetail(url) {
62
+ const p = seg(url);
63
+ if (p[0] !== "package" || !p[1])
64
+ return null;
65
+ return p[1].startsWith("@") && p[2] ? `${p[1]}/${p[2]}` : p[1];
66
+ }
67
+ function wikipediaDetail(url) {
68
+ const p = seg(url);
69
+ const i = p.indexOf("wiki");
70
+ const article = i !== -1 ? p[i + 1] : undefined;
71
+ if (!article)
72
+ return null;
73
+ try {
74
+ return decodeURIComponent(article).replace(/_/g, " ");
75
+ }
76
+ catch {
77
+ return article.replace(/_/g, " ");
78
+ }
79
+ }
80
+ function steamDetail(url) {
81
+ const p = seg(url);
82
+ if (p[0] === "app" && p[2])
83
+ return decodeURIComponent(p[2]).replace(/_/g, " ");
84
+ if (p[0] === "app" && p[1])
85
+ return `App ${p[1]}`;
86
+ return null;
87
+ }
88
+ /** Seventy-three sites, and the seven that also read a line out of the path. */
89
+ export const LINK_PROVIDERS = [
90
+ { id: "github", label: "GitHub", brand: "#181717", brandDark: "#8B949E", hosts: ["github.com", "gist.github.com"], detail: githubDetail },
91
+ { id: "gitlab", label: "GitLab", brand: "#FC6D26", hosts: ["gitlab.com"] },
92
+ { id: "npm", label: "npm", brand: "#CB3837", hosts: ["npmjs.com"], detail: npmDetail },
93
+ { id: "pypi", label: "PyPI", brand: "#3775A9", hosts: ["pypi.org"] },
94
+ { id: "crates", label: "crates.io", brand: "#B7410E", hosts: ["crates.io", "docs.rs"] },
95
+ { id: "docker", label: "Docker Hub", brand: "#2496ED", hosts: ["hub.docker.com"] },
96
+ { id: "stackoverflow", label: "Stack Overflow", brand: "#F58025", hosts: ["stackoverflow.com", "superuser.com", "serverfault.com"], hostSuffixes: [".stackexchange.com"] },
97
+ { id: "codepen", label: "CodePen", brand: "#111111", brandDark: "#C9CDD3", hosts: ["codepen.io"] },
98
+ { id: "codesandbox", label: "CodeSandbox", brand: "#151515", brandDark: "#B9BEC5", hosts: ["codesandbox.io"] },
99
+ { id: "stackblitz", label: "StackBlitz", brand: "#1269D3", hosts: ["stackblitz.com"] },
100
+ { id: "replit", label: "Replit", brand: "#F26207", hosts: ["replit.com"] },
101
+ { id: "vercel", label: "Vercel", brand: "#000000", brandDark: "#B4B4B4", hosts: ["vercel.com"] },
102
+ { id: "netlify", label: "Netlify", brand: "#00C7B7", hosts: ["netlify.com", "netlify.app"] },
103
+ { id: "cloudflare", label: "Cloudflare", brand: "#F38020", hosts: ["cloudflare.com"] },
104
+ { id: "modrinth", label: "Modrinth", brand: "#00AF5C", hosts: ["modrinth.com"], detail: modrinthDetail },
105
+ { id: "curseforge", label: "CurseForge", brand: "#F16436", hosts: ["curseforge.com"] },
106
+ { id: "nexusmods", label: "Nexus Mods", brand: "#D98F40", hosts: ["nexusmods.com"] },
107
+ { id: "steam", label: "Steam", brand: "#1B2838", brandDark: "#8BA5C4", hosts: ["store.steampowered.com", "steamcommunity.com"], detail: steamDetail },
108
+ { id: "itch", label: "itch.io", brand: "#FA5C5C", hosts: ["itch.io"], hostSuffixes: [".itch.io"] },
109
+ { id: "gog", label: "GOG", brand: "#86328A", hosts: ["gog.com"] },
110
+ { id: "epic", label: "Epic Games", brand: "#2A2A2A", brandDark: "#B0B0B0", hosts: ["store.epicgames.com", "epicgames.com"] },
111
+ { id: "playstation", label: "PlayStation", brand: "#0070D1", hosts: ["playstation.com", "store.playstation.com"] },
112
+ { id: "nintendo", label: "Nintendo", brand: "#E60012", hosts: ["nintendo.com"] },
113
+ { id: "youtube", label: "YouTube", brand: "#FF0000", hosts: ["youtube.com", "youtu.be", "m.youtube.com", "music.youtube.com"] },
114
+ { id: "twitch", label: "Twitch", brand: "#9146FF", hosts: ["twitch.tv", "clips.twitch.tv"] },
115
+ { id: "vimeo", label: "Vimeo", brand: "#1AB7EA", hosts: ["vimeo.com", "player.vimeo.com"] },
116
+ { id: "kick", label: "Kick", brand: "#0FA33C", hosts: ["kick.com"] },
117
+ { id: "rumble", label: "Rumble", brand: "#85C742", hosts: ["rumble.com"] },
118
+ { id: "spotify", label: "Spotify", brand: "#1DB954", hosts: ["open.spotify.com"] },
119
+ { id: "applemusic", label: "Apple Music", brand: "#FA243C", hosts: ["music.apple.com"] },
120
+ { id: "soundcloud", label: "SoundCloud", brand: "#FF5500", hosts: ["soundcloud.com", "on.soundcloud.com"] },
121
+ { id: "bandcamp", label: "Bandcamp", brand: "#408294", hosts: ["bandcamp.com"], hostSuffixes: [".bandcamp.com"] },
122
+ { id: "tidal", label: "Tidal", brand: "#1A1A1A", brandDark: "#B0B0B0", hosts: ["tidal.com"] },
123
+ { id: "mixcloud", label: "Mixcloud", brand: "#5000FF", hosts: ["mixcloud.com"] },
124
+ { id: "lastfm", label: "Last.fm", brand: "#D51007", hosts: ["last.fm"] },
125
+ { id: "x", label: "X", brand: "#0F1419", brandDark: "#C4CDD5", hosts: ["x.com", "twitter.com"] },
126
+ { id: "bluesky", label: "Bluesky", brand: "#0285FF", hosts: ["bsky.app"] },
127
+ { id: "mastodon", label: "Mastodon", brand: "#6364FF", hosts: ["mastodon.social", "mastodon.online", "fosstodon.org"] },
128
+ { id: "reddit", label: "Reddit", brand: "#FF4500", hosts: ["reddit.com", "old.reddit.com", "redd.it"], detail: redditDetail },
129
+ { id: "facebook", label: "Facebook", brand: "#0866FF", hosts: ["facebook.com", "fb.com", "fb.watch", "m.facebook.com"] },
130
+ { id: "instagram", label: "Instagram", brand: "#E4405F", hosts: ["instagram.com"] },
131
+ { id: "threads", label: "Threads", brand: "#101010", brandDark: "#BFBFBF", hosts: ["threads.net", "threads.com"] },
132
+ { id: "tiktok", label: "TikTok", brand: "#EE1D52", hosts: ["tiktok.com", "vm.tiktok.com", "vt.tiktok.com"] },
133
+ { id: "linkedin", label: "LinkedIn", brand: "#0A66C2", hosts: ["linkedin.com"] },
134
+ { id: "pinterest", label: "Pinterest", brand: "#BD081C", hosts: ["pinterest.com"], hostSuffixes: [".pinterest.com"] },
135
+ { id: "snapchat", label: "Snapchat", brand: "#B8B400", hosts: ["snapchat.com"] },
136
+ { id: "telegram", label: "Telegram", brand: "#26A5E4", hosts: ["t.me", "telegram.me"] },
137
+ { id: "discord", label: "Discord", brand: "#5865F2", hosts: ["discord.com", "discord.gg", "discordapp.com"] },
138
+ { id: "wikipedia", label: "Wikipedia", brand: "#3366CC", hosts: ["wikipedia.org"], hostSuffixes: [".wikipedia.org"], detail: wikipediaDetail },
139
+ { id: "mdn", label: "MDN Web Docs", brand: "#1B76C4", hosts: ["developer.mozilla.org"] },
140
+ { id: "hackernews", label: "Hacker News", brand: "#FF6600", hosts: ["news.ycombinator.com"] },
141
+ { id: "medium", label: "Medium", brand: "#1A1A1A", brandDark: "#C0C0C0", hosts: ["medium.com"], hostSuffixes: [".medium.com"] },
142
+ { id: "devto", label: "DEV", brand: "#3B49DF", hosts: ["dev.to"] },
143
+ { id: "arxiv", label: "arXiv", brand: "#B31B1B", hosts: ["arxiv.org"] },
144
+ { id: "archive", label: "Internet Archive", brand: "#4A4A4A", brandDark: "#B5B5B5", hosts: ["archive.org", "web.archive.org"] },
145
+ { id: "wolfram", label: "Wolfram Alpha", brand: "#DD1100", hosts: ["wolframalpha.com"] },
146
+ { id: "goodreads", label: "Goodreads", brand: "#75633F", hosts: ["goodreads.com"] },
147
+ { id: "imdb", label: "IMDb", brand: "#C9A227", hosts: ["imdb.com"] },
148
+ { id: "letterboxd", label: "Letterboxd", brand: "#00B020", hosts: ["letterboxd.com"] },
149
+ { id: "imgur", label: "Imgur", brand: "#1BB76E", hosts: ["imgur.com", "i.imgur.com"] },
150
+ { id: "giphy", label: "Giphy", brand: "#FF6666", hosts: ["giphy.com"] },
151
+ { id: "flickr", label: "Flickr", brand: "#0063DC", hosts: ["flickr.com"] },
152
+ { id: "figma", label: "Figma", brand: "#F24E1E", hosts: ["figma.com"] },
153
+ { id: "dribbble", label: "Dribbble", brand: "#EA4C89", hosts: ["dribbble.com"] },
154
+ { id: "behance", label: "Behance", brand: "#1769FF", hosts: ["behance.net"] },
155
+ { id: "notion", label: "Notion", brand: "#2F2F2F", brandDark: "#BFBFBF", hosts: ["notion.so", "notion.site"], hostSuffixes: [".notion.site"] },
156
+ { id: "linear", label: "Linear", brand: "#5E6AD2", hosts: ["linear.app"] },
157
+ { id: "trello", label: "Trello", brand: "#0052CC", hosts: ["trello.com"] },
158
+ { id: "jira", label: "Jira", brand: "#0052CC", hosts: ["atlassian.net"], hostSuffixes: [".atlassian.net"] },
159
+ { id: "obsidian", label: "Obsidian", brand: "#7C3AED", hosts: ["obsidian.md"] },
160
+ { id: "googledrive", label: "Google Drive", brand: "#1A73E8", hosts: ["drive.google.com", "docs.google.com"] },
161
+ { id: "googlemaps", label: "Google Maps", brand: "#34A853", hosts: ["maps.google.com", "maps.app.goo.gl"] },
162
+ { id: "openstreetmap", label: "OpenStreetMap", brand: "#5A8B3E", hosts: ["openstreetmap.org"] },
163
+ ];
164
+ const BY_HOST = new Map();
165
+ for (const provider of LINK_PROVIDERS) {
166
+ for (const host of provider.hosts)
167
+ BY_HOST.set(host, provider);
168
+ }
169
+ /** The hostname without `www.`, or the input when that was not a URL. */
170
+ export function hostnameOf(url) {
171
+ try {
172
+ return new URL(url).hostname.replace(/^www\./, "");
173
+ }
174
+ catch {
175
+ return url;
176
+ }
177
+ }
178
+ export function getLinkProvider(url) {
179
+ const host = hostnameOf(url).toLowerCase();
180
+ const exact = BY_HOST.get(host);
181
+ if (exact)
182
+ return exact;
183
+ for (const provider of LINK_PROVIDERS) {
184
+ if (provider.hostSuffixes?.some((suffix) => host.endsWith(suffix)))
185
+ return provider;
186
+ }
187
+ return null;
188
+ }
189
+ /** The line a provider can read out of the path, if it knows how. */
190
+ export function getProviderDetail(url) {
191
+ const provider = getLinkProvider(url);
192
+ if (!provider?.detail)
193
+ return null;
194
+ try {
195
+ return provider.detail(new URL(url));
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ /**
202
+ * The accent a card is drawn with: the brand where we know it, the colour the
203
+ * page declared where we do not, and null for the app's own accent when
204
+ * neither is on offer.
205
+ */
206
+ export function getAccentColor(url, themeColor, appearance) {
207
+ const provider = getLinkProvider(url);
208
+ if (provider) {
209
+ return appearance === "dark" ? (provider.brandDark ?? provider.brand) : provider.brand;
210
+ }
211
+ return themeColor ?? null;
212
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The shape `Gryt-chat/reports` takes, and how an app fills it in.
3
+ *
4
+ * `POST /v1/reports`. Only `type` and `message` are required — everything else
5
+ * is diagnostics, and the service is explicit that a field an app gets wrong is
6
+ * truncated or dropped rather than a reason to reject: "a report lost to a
7
+ * validation error is a bug nobody hears about."
8
+ *
9
+ * That cuts both ways, and it is why nothing here throws and nothing here is
10
+ * required to succeed. A diagnostic an app cannot work out is left off rather
11
+ * than sent as a guess, because a wrong Electron version in a bug report is
12
+ * worse than no Electron version.
13
+ *
14
+ * ## Why this is one file and not two
15
+ *
16
+ * The desktop and the phone had a copy each, with the same seven exports and
17
+ * different insides. Most of the difference was real: a renderer knows its
18
+ * Chrome build, a phone knows whether it is an emulator, and neither should
19
+ * send the other's fields empty. Some of it was not. `MESSAGE_MAX` was 8000 on
20
+ * the desktop and 4000 on the phone, and nothing recorded a reason — the two
21
+ * files even carried the same sentence about not cutting people off at a tweet.
22
+ *
23
+ * So `Diagnostics` below is the union of both, every field optional, and each
24
+ * app fills what it can actually find out. A field neither app fills is dead
25
+ * and can be seen to be dead, which is the other half of the point.
26
+ */
27
+ export type ReportType = "bug" | "feedback";
28
+ /** The fields an app can fill. The service accepts more. */
29
+ export interface Report {
30
+ type: ReportType;
31
+ message: string;
32
+ title?: string;
33
+ app?: {
34
+ version?: string;
35
+ /** The build number, where the platform has one distinct from the version. */
36
+ build?: string;
37
+ channel?: string;
38
+ /** Random per install, not per person. What rate limits are counted against. */
39
+ installId?: string;
40
+ locale?: string;
41
+ };
42
+ device?: {
43
+ platform?: string;
44
+ osVersion?: string;
45
+ /** A crash only simulators produce is worth telling apart from a real one. */
46
+ isEmulator?: boolean;
47
+ screen?: {
48
+ width: number;
49
+ height: number;
50
+ scale: number;
51
+ };
52
+ timezone?: string;
53
+ };
54
+ runtime?: {
55
+ engine?: string;
56
+ chromeVersion?: string;
57
+ electronVersion?: string;
58
+ reactNativeVersion?: string;
59
+ expoVersion?: string;
60
+ userAgent?: string;
61
+ };
62
+ context?: {
63
+ route?: string;
64
+ serverVersion?: string;
65
+ connected?: boolean;
66
+ voiceActive?: boolean;
67
+ online?: boolean;
68
+ networkType?: string;
69
+ /** "It broke twenty minutes in" and "it broke on launch" are different bugs. */
70
+ sessionUptimeSec?: number;
71
+ };
72
+ /** The tail of the app's own log, where it keeps one. */
73
+ logs?: string[];
74
+ extra?: Record<string, unknown>;
75
+ }
76
+ /**
77
+ * What an app knows about itself when somebody opens the form.
78
+ *
79
+ * Passed in rather than read here, so the assembly below stays readable and
80
+ * testable: every one of these comes from somewhere that needs a running app —
81
+ * the Electron bridge, `expo-constants`, `Platform`, the socket layer.
82
+ */
83
+ export interface Diagnostics {
84
+ version?: string | null;
85
+ build?: string | null;
86
+ channel?: string | null;
87
+ installId?: string | null;
88
+ locale?: string | null;
89
+ platform?: string | null;
90
+ /** A number on some platforms: Android's API level arrives as one. */
91
+ osVersion?: string | number | null;
92
+ isEmulator?: boolean | null;
93
+ screen?: {
94
+ width: number;
95
+ height: number;
96
+ scale: number;
97
+ } | null;
98
+ timezone?: string | null;
99
+ engine?: string | null;
100
+ chromeVersion?: string | null;
101
+ electronVersion?: string | null;
102
+ reactNativeVersion?: string | null;
103
+ expoVersion?: string | null;
104
+ userAgent?: string | null;
105
+ route?: string | null;
106
+ serverVersion?: string | null;
107
+ connected?: boolean | null;
108
+ voiceActive?: boolean | null;
109
+ online?: boolean | null;
110
+ networkType?: string | null;
111
+ sessionUptimeSec?: number | null;
112
+ /** Whether this app is running the server it is connected to. */
113
+ embeddedServer?: boolean | null;
114
+ /** The version of that embedded server, which is not the app's. */
115
+ embeddedServerVersion?: string | null;
116
+ logs?: string[];
117
+ }
118
+ /**
119
+ * The service truncates, but an app should not send a novel either.
120
+ *
121
+ * Generous rather than tight: somebody describing a bug properly is the good
122
+ * case, and cutting them off at a tweet is how you get "it broke" instead. The
123
+ * desktop's 8000 wins over the phone's 4000 on exactly that argument, which
124
+ * both files made and only one of them followed.
125
+ */
126
+ export declare const MESSAGE_MAX = 8000;
127
+ export declare const TITLE_MAX = 120;
128
+ /**
129
+ * Assemble what gets sent.
130
+ *
131
+ * `type` and `message` always; every diagnostic only if it is actually known.
132
+ * An empty `device` object says "this app does not collect device
133
+ * information", which is a different and wronger claim than leaving it off.
134
+ */
135
+ export declare function buildReport(type: ReportType, input: {
136
+ message: string;
137
+ title?: string;
138
+ }, diagnostics?: Diagnostics): Report;
139
+ /**
140
+ * The same diagnostics, as lines to show somebody before they send.
141
+ *
142
+ * A form that quietly ships a route, a server version and a log tail is worse
143
+ * than one that says so — and this is the list that makes "what is attached"
144
+ * answerable without reading the source. It is built from the report rather
145
+ * than from the inputs, so it cannot drift from what actually goes.
146
+ *
147
+ * Every row is conditional, so an app that fills half the fields shows half the
148
+ * rows rather than a column of blanks.
149
+ */
150
+ export declare function describeAttached(report: Report): {
151
+ label: string;
152
+ value: string;
153
+ }[];