@gryt/core 0.6.0 → 0.7.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/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { getAccentColor, getLinkProvider, getProviderDetail, hostnameOf, LINK_PR
3
3
  export { getProviderLogo, LINK_PROVIDER_LOGOS, LOGO_VIEW_BOX, } from "./links/logos.js";
4
4
  export { describePreviewFailure, extractUrls, getCardSubtitle, getLinkCardLayout, type LinkCardLayout, type LinkPreviewData, } from "./links/preview.js";
5
5
  export { conversationTitle, type DirectConversation, } from "./conversations/title.js";
6
- export { normalizeCode, normalizeHost, parseServerInput, type ServerInput, } from "./servers/address.js";
6
+ export { inviteLink, isPublicHost, normalizeCode, normalizeHost, parseServerInput, type ServerInput, } from "./servers/address.js";
7
7
  export { NAME_POOL, pickRandomName } from "./profile/randomName.js";
8
8
  export { cellState, CUSTOM_VALUE, describeRules, EVERYONE_VALUE, indexRules, nextCellState, scopeChoiceFromValue, scopeChoiceValue, scopeOptions, scopeSetPayload, withCell, type CellState, type ChannelRule, type RuleEffect, type ScopeChoice, } from "./permissions/channelRules.js";
9
9
  export { groupMembersByRole, OFFLINE_GROUP_KEY, UNGROUPED_GROUP_KEY, type GroupableMember, type GroupableRole, type MemberGroup, } from "./members/groups.js";
package/dist/index.js CHANGED
@@ -1,12 +1,11 @@
1
- /* What belongs here: it must compile with no DOM and no React Native, and both
2
- apps must otherwise need a copy. The barrel is flat and named because
3
- `check-public-surface.mjs` asserts against it. */
1
+ /* What belongs here: it must compile with no DOM and no React Native, and both apps must
2
+ otherwise need a copy. The barrel is flat and named, because the surface check reads it. */
4
3
  export { buildReport, describeAttached, MESSAGE_MAX, TITLE_MAX, } from "./reports/report.js";
5
4
  export { getAccentColor, getLinkProvider, getProviderDetail, hostnameOf, LINK_PROVIDERS, } from "./links/providers.js";
6
5
  export { getProviderLogo, LINK_PROVIDER_LOGOS, LOGO_VIEW_BOX, } from "./links/logos.js";
7
6
  export { describePreviewFailure, extractUrls, getCardSubtitle, getLinkCardLayout, } from "./links/preview.js";
8
7
  export { conversationTitle, } from "./conversations/title.js";
9
- export { normalizeCode, normalizeHost, parseServerInput, } from "./servers/address.js";
8
+ export { inviteLink, isPublicHost, normalizeCode, normalizeHost, parseServerInput, } from "./servers/address.js";
10
9
  export { NAME_POOL, pickRandomName } from "./profile/randomName.js";
11
10
  export { cellState, CUSTOM_VALUE, describeRules, EVERYONE_VALUE, indexRules, nextCellState, scopeChoiceFromValue, scopeChoiceValue, scopeOptions, scopeSetPayload, withCell, } from "./permissions/channelRules.js";
12
11
  export { groupMembersByRole, OFFLINE_GROUP_KEY, UNGROUPED_GROUP_KEY, } from "./members/groups.js";
@@ -1,18 +1,5 @@
1
- /* The artwork for the providers in providers.ts, as raw SVG path data.
2
-
3
- It used to live in the desktop client as react-icons components, which meant
4
- the phone had no logos at all and the client carried an 82MB dependency to
5
- draw 73 of them. Path data is not DOM, so it can sit here with the hostnames
6
- and brand colours it belongs with, and each app draws it with whatever it
7
- has: <path> in the browser, <Path> from react-native-svg on the phone.
8
-
9
- Every one is a single path on a 24x24 canvas, which is Simple Icons' own
10
- format and is asserted in logos.test.ts. A provider with no logo here falls
11
- back to the site's favicon, so adding a provider without adding artwork
12
- degrades rather than breaks.
13
-
14
- The marks belong to the companies they identify. Being here is so Gryt can
15
- point at those sites, not a claim on them. */
1
+ /* The artwork for the providers in providers.ts, as raw SVG path data, so both apps draw it
2
+ with what they have. The marks belong to the companies they identify. */
16
3
  /** Every logo below is drawn on this canvas. */
17
4
  export const LOGO_VIEW_BOX = "0 0 24 24";
18
5
  /** Provider id, as in {@link LINK_PROVIDERS}, to its SVG path data. */
@@ -20,35 +20,23 @@ export interface LinkPreviewData {
20
20
  status?: number | null;
21
21
  }
22
22
  /**
23
- * The links in a message, minus the ones that are already something else.
24
- *
25
- * Code spans and fences are stripped first: a URL inside backticks is being
26
- * quoted rather than shared, and a card under it would be noise. Markdown
27
- * images are stripped because the picture is already drawn.
23
+ * The links in a message, minus the ones that are already something else: a URL in backticks
24
+ * is being quoted rather than shared, and a markdown image is already drawn.
28
25
  */
29
26
  export declare function extractUrls(text: string | null): string[];
30
27
  /**
31
- * How much of a card a preview can fill.
32
- *
33
- * A wide image wants to sit under the text at full width. A small or square one
34
- * wants to be a thumbnail beside it. No image wants no space set aside for a
35
- * picture at all. Drawing all three the same way is what produced a hostname
36
- * next to an empty grey rectangle.
28
+ * How much of a card a preview can fill. A wide image sits under the text, a small one
29
+ * beside it, and no image takes no space — drawing all three alike gave a grey rectangle.
37
30
  */
38
31
  export type LinkCardLayout = "large" | "thumbnail" | "text" | "bare";
39
32
  export declare function getLinkCardLayout(data: LinkPreviewData): LinkCardLayout;
40
33
  /**
41
- * Why a page gave us nothing, in words worth showing somebody.
42
- *
43
- * Only for statuses that mean something to a reader. A 500 is the site's
44
- * problem and saying so helps nobody, so it returns null and the card falls
45
- * back to showing the link on its own.
34
+ * Why a page gave us nothing, in words worth showing somebody. Only for statuses that mean
35
+ * something to a reader: a 500 is the site's problem, so it returns null.
46
36
  */
47
37
  export declare function describePreviewFailure(status: number | null | undefined): string | null;
48
38
  /**
49
- * The line under the title, where the path says something the title does not.
50
- *
51
- * Wikipedia titles its WebRTC page "WebRTC" and the detail read out of
52
- * `/wiki/WebRTC` is "WebRTC", so showing both printed the word twice.
39
+ * The line under the title, where the path says something the title does not. Wikipedia
40
+ * titles its WebRTC page "WebRTC", and `/wiki/WebRTC` printed the word twice.
53
41
  */
54
42
  export declare function getCardSubtitle(title: string | null, detail: string | null): string | null;
@@ -2,11 +2,8 @@
2
2
  the two apps reach a server differently. */
3
3
  const URL_REGEX = /https?:\/\/[^\s<>[\](){}'"`,]+[^\s<>[\](){}'"`,.:;!?)]/gi;
4
4
  /**
5
- * The links in a message, minus the ones that are already something else.
6
- *
7
- * Code spans and fences are stripped first: a URL inside backticks is being
8
- * quoted rather than shared, and a card under it would be noise. Markdown
9
- * images are stripped because the picture is already drawn.
5
+ * The links in a message, minus the ones that are already something else: a URL in backticks
6
+ * is being quoted rather than shared, and a markdown image is already drawn.
10
7
  */
11
8
  export function extractUrls(text) {
12
9
  if (!text)
@@ -28,10 +25,8 @@ export function getLinkCardLayout(data) {
28
25
  return hasText ? "text" : "bare";
29
26
  const w = data.imageWidth;
30
27
  const h = data.imageHeight;
31
- /* Unknown dimensions count as large. A site that sets og:image and says
32
- nothing about its size has almost always set a share card, and the ones
33
- that have not lose less by being drawn big than a real share card loses by
34
- being shrunk into a corner. */
28
+ /* Unknown dimensions count as large: a site that sets og:image and says nothing about its
29
+ size has almost always set a share card, and those lose most by being shrunk. */
35
30
  if (!w || !h)
36
31
  return "large";
37
32
  if (w >= LARGE_IMAGE_MIN_WIDTH && w / h >= LARGE_IMAGE_MIN_ASPECT)
@@ -39,21 +34,16 @@ export function getLinkCardLayout(data) {
39
34
  return hasText ? "thumbnail" : "large";
40
35
  }
41
36
  /**
42
- * Why a page gave us nothing, in words worth showing somebody.
43
- *
44
- * Only for statuses that mean something to a reader. A 500 is the site's
45
- * problem and saying so helps nobody, so it returns null and the card falls
46
- * back to showing the link on its own.
37
+ * Why a page gave us nothing, in words worth showing somebody. Only for statuses that mean
38
+ * something to a reader: a 500 is the site's problem, so it returns null.
47
39
  */
48
40
  export function describePreviewFailure(status) {
49
41
  if (status == null)
50
42
  return null;
51
43
  if (status === 401)
52
44
  return "Sign-in only";
53
- /* Not "private": a 403 is as often a site refusing our fetcher as it is a
54
- page somebody is not allowed to see. Stack Overflow answers 403 to the
55
- preview fetch and 200 to a browser. A private GitHub repository answers
56
- 404 and is covered below. */
45
+ /* Not "private": a 403 is as often a site refusing our fetcher as a page somebody may not
46
+ see. Stack Overflow answers 403 to the fetch and 200 to a browser. */
57
47
  if (status === 403)
58
48
  return "The site would not let us look";
59
49
  if (status === 404 || status === 410)
@@ -63,10 +53,8 @@ export function describePreviewFailure(status) {
63
53
  return null;
64
54
  }
65
55
  /**
66
- * The line under the title, where the path says something the title does not.
67
- *
68
- * Wikipedia titles its WebRTC page "WebRTC" and the detail read out of
69
- * `/wiki/WebRTC` is "WebRTC", so showing both printed the word twice.
56
+ * The line under the title, where the path says something the title does not. Wikipedia
57
+ * titles its WebRTC page "WebRTC", and `/wiki/WebRTC` printed the word twice.
70
58
  */
71
59
  export function getCardSubtitle(title, detail) {
72
60
  if (!detail || !title)
@@ -16,8 +16,7 @@ export declare function getLinkProvider(url: string): LinkProvider | null;
16
16
  /** The line a provider can read out of the path, if it knows how. */
17
17
  export declare function getProviderDetail(url: string): string | null;
18
18
  /**
19
- * The accent a card is drawn with: the brand where we know it, the colour the
20
- * page declared where we do not, and null for the app's own accent when
21
- * neither is on offer.
19
+ * The accent a card is drawn with: the brand where we know it, the colour the page declared
20
+ * where we do not, and null for the app's own accent when neither is on offer.
22
21
  */
23
22
  export declare function getAccentColor(url: string, themeColor: string | null | undefined, appearance: "light" | "dark"): string | null;
@@ -1,10 +1,5 @@
1
- /* Not the list of sites that work — any site with OpenGraph gets a full card
2
- without being here. Being here buys a real logo and a brand colour.
3
-
4
- The logos are in logos.ts, as path data rather than components, because that
5
- is the part both apps can share without this package touching a DOM. They
6
- used to be react-icons components in the desktop client and nothing at all
7
- on the phone. */
1
+ /* Not the list of sites that work — any site with OpenGraph gets a full card. Being here
2
+ buys a real logo and a brand colour, and the logos are path data in logos.ts. */
8
3
  const seg = (url) => url.pathname.split("/").filter(Boolean);
9
4
  function githubDetail(url) {
10
5
  const p = seg(url);
@@ -65,13 +60,8 @@ function wikipediaDetail(url) {
65
60
  }
66
61
  }
67
62
  /**
68
- * The model's name out of `/models/1642496-old-vikings-jewelry-box`.
69
- *
70
- * The number is the id their API takes and means nothing to a reader, so the
71
- * slug behind it is what the line says. A link with the slug stripped —
72
- * `/models/1642496`, which is what their own copy button produces — has
73
- * nothing to add, and null leaves the card to its title rather than printing
74
- * an id at somebody.
63
+ * The model's name out of `/models/1642496-old-vikings-jewelry-box`: the number is an id
64
+ * that says nothing, and a link with the slug stripped gets null rather than the id.
75
65
  */
76
66
  function makerWorldDetail(url) {
77
67
  const p = seg(url);
@@ -219,9 +209,8 @@ export function getProviderDetail(url) {
219
209
  }
220
210
  }
221
211
  /**
222
- * The accent a card is drawn with: the brand where we know it, the colour the
223
- * page declared where we do not, and null for the app's own accent when
224
- * neither is on offer.
212
+ * The accent a card is drawn with: the brand where we know it, the colour the page declared
213
+ * where we do not, and null for the app's own accent when neither is on offer.
225
214
  */
226
215
  export function getAccentColor(url, themeColor, appearance) {
227
216
  const provider = getLinkProvider(url);
@@ -1,31 +1,10 @@
1
1
  /**
2
- * The member list, cut into role groups.
3
- *
4
- * Both apps drew this and both got it right, separately, which is the shape of
5
- * duplicate that goes wrong quietly: nothing fails when the two drift, the
6
- * lists just stop matching, and a moderator has to re-learn the sidebar on
7
- * whichever device they are holding.
8
- *
9
- * **Offline leaves its role** — the question the list answers is "who is
10
- * around", so they go to one group at the end in one alphabet rather than to
11
- * the bottom of each role.
12
- *
13
- * Roles run highest rank first, and roles nobody holds are left out rather than
14
- * drawn empty. A member whose role the server did not describe lands in one
15
- * unnamed group after the named ones, which is also what a server too old to
16
- * send roles looks like.
2
+ * The member list, cut into role groups. Offline leaves its role and goes to one group at
3
+ * the end: the question the list answers is who is around.
17
4
  */
18
5
  /**
19
- * What this needs to know about a member, and no more.
20
- *
21
- * Structural rather than a type the apps have to adopt: the desktop's
22
- * `MemberInfo` and the phone's `Member` carry different fields for different
23
- * screens, and both already satisfy this.
24
- *
25
- * **A `status` this does not recognise counts as present, `undefined`
26
- * included.** The server always sends one — `clients.ts` defaults it to
27
- * `offline` — so the absent case is a server too old to have the field, and on
28
- * one of those every member reading as offline would blank the whole list.
6
+ * What this needs to know about a member, and no more, structurally. A `status` this does
7
+ * not recognise counts as present, `undefined` included — that is an old server.
29
8
  */
30
9
  export interface GroupableMember {
31
10
  nickname?: string | null;
@@ -40,12 +19,8 @@ export interface GroupableRole {
40
19
  color?: string | null;
41
20
  }
42
21
  /**
43
- * One block of the member list: a heading and the people under it.
44
- *
45
- * Generic over the member so a caller gets its own type back and can draw
46
- * whatever it stores. `color` is the role's own colour, straight from the
47
- * server, or null for a role that has none and for the two groups that are not
48
- * roles.
22
+ * One block of the member list: a heading and the people under it. Generic over the member,
23
+ * so a caller gets its own type back. `color` is the role's own, or null.
49
24
  */
50
25
  export interface MemberGroup<M> {
51
26
  key: string;
@@ -1,19 +1,6 @@
1
1
  /**
2
- * The member list, cut into role groups.
3
- *
4
- * Both apps drew this and both got it right, separately, which is the shape of
5
- * duplicate that goes wrong quietly: nothing fails when the two drift, the
6
- * lists just stop matching, and a moderator has to re-learn the sidebar on
7
- * whichever device they are holding.
8
- *
9
- * **Offline leaves its role** — the question the list answers is "who is
10
- * around", so they go to one group at the end in one alphabet rather than to
11
- * the bottom of each role.
12
- *
13
- * Roles run highest rank first, and roles nobody holds are left out rather than
14
- * drawn empty. A member whose role the server did not describe lands in one
15
- * unnamed group after the named ones, which is also what a server too old to
16
- * send roles looks like.
2
+ * The member list, cut into role groups. Offline leaves its role and goes to one group at
3
+ * the end: the question the list answers is who is around.
17
4
  */
18
5
  /**
19
6
  * The key on the group holding anybody not offline, when the server named no
@@ -18,44 +18,30 @@ export type ScopeChoice = {
18
18
  /** The dropdown value that means "this channel has its own rules". */
19
19
  export declare const CUSTOM_VALUE = "custom";
20
20
  /**
21
- * The dropdown value that means "no rules at all".
22
- *
23
- * A word rather than an empty string: the Select reads "" as "nothing chosen"
24
- * and paints its placeholder over the label, so a channel open to everyone
25
- * showed "Select" instead of "Everyone". See [[base-ui-select-empty-value]] —
26
- * the Max Bitrate control in the same dialog already used "default" for this.
21
+ * The dropdown value that means "no rules at all". A word rather than an empty string: the
22
+ * Select reads "" as "nothing chosen" and paints its placeholder over the label.
27
23
  */
28
24
  export declare const EVERYONE_VALUE = "everyone";
29
25
  /** Look up one cell without scanning the whole list per cell. */
30
26
  export declare function indexRules(rules: ChannelRule[]): Map<string, RuleEffect>;
31
27
  export declare function cellState(index: Map<string, RuleEffect>, roleId: string, permission: string): CellState;
32
28
  /**
33
- * The next state when somebody clicks a cell.
34
- *
35
- * inherit to deny to allow and back to inherit. Deny comes first deliberately:
36
- * taking something away is what people open this to do, and it puts the
37
- * dangerous state one click from neutral rather than two.
29
+ * The next state when somebody clicks a cell: inherit, deny, allow, inherit. Deny first,
30
+ * because taking something away is what people open this to do.
38
31
  */
39
32
  export declare function nextCellState(current: CellState): CellState;
40
33
  /** Set one cell, dropping the row entirely when it goes back to inherit. */
41
34
  export declare function withCell(rules: ChannelRule[], roleId: string, permission: string, state: CellState): ChannelRule[];
42
35
  /**
43
- * Which dropdown value a channel is showing.
44
- *
45
- * The server sends the scope id plus whether it is a template. A scope that is
46
- * not a template is this channel's own, which is Custom; no scope at all is
47
- * Everyone.
36
+ * Which dropdown value a channel is showing. A scope that is not a template is this
37
+ * channel's own, which is Custom; no scope at all is Everyone.
48
38
  */
49
39
  export declare function scopeChoiceValue(scopeId: string | null, isTemplate: boolean): string;
50
40
  /** Turn the dropdown value back into what the server event wants. */
51
41
  export declare function scopeChoiceFromValue(value: string): ScopeChoice;
52
42
  /**
53
- * The payload for `server:channels:scope:set`.
54
- *
55
- * Custom sends its rules; the other two send none. Templates deliberately do
56
- * not carry rules here — editing a template from a channel would change every
57
- * other channel using it, which is the opposite of what somebody expects from
58
- * a screen titled with one channel's name.
43
+ * The payload for `server:channels:scope:set`. Templates deliberately do not carry rules:
44
+ * editing one from a channel would change every other channel using it.
59
45
  */
60
46
  export declare function scopeSetPayload(choice: ScopeChoice, rules: ChannelRule[]): {
61
47
  templateId?: string | null;
@@ -63,11 +49,8 @@ export declare function scopeSetPayload(choice: ScopeChoice, rules: ChannelRule[
63
49
  rules?: ChannelRule[];
64
50
  };
65
51
  /**
66
- * The dropdown options: Everyone, then each template, then Custom.
67
- *
68
- * Custom last because it is the escape hatch. Somebody scanning the list should
69
- * meet the shared answers first — the whole point of templates is that reaching
70
- * for Custom is the uncommon choice.
52
+ * The dropdown options: Everyone, then each template, then Custom. Custom last, because it
53
+ * is the escape hatch and reaching for it is the uncommon choice.
71
54
  */
72
55
  export declare function scopeOptions(templates: {
73
56
  id: string;
@@ -77,11 +60,7 @@ export declare function scopeOptions(templates: {
77
60
  value: string;
78
61
  }[];
79
62
  /**
80
- * A one-line summary of what a set of rules does, for the note under the
81
- * dropdown.
82
- *
83
- * Reading is called out on its own because it is the rule with a different
84
- * consequence: a role denied `read_messages` is not shown a locked channel,
85
- * it is shown nothing at all, and somebody setting that deserves to be told.
63
+ * A one-line summary of what a set of rules does. Reading is called out on its own: a role
64
+ * denied `read_messages` is shown nothing at all rather than a locked channel.
86
65
  */
87
66
  export declare function describeRules(rules: ChannelRule[], roleNames: Map<string, string>): string;
@@ -1,18 +1,10 @@
1
- /* Inherit is the absence of a row, so a cell has three states and only two are
2
- ever written.
3
-
4
- `indexRules` keys on role and permission joined by NUL, because NUL cannot
5
- appear in either half. Written as `\u0000` rather than a raw byte: literal
6
- NULs make git treat the file as binary and stop showing diffs. */
1
+ /* Inherit is the absence of a row, so a cell has three states and only two are written.
2
+ `indexRules` keys on NUL, written as `\u0000` or git treats the file as binary. */
7
3
  /** The dropdown value that means "this channel has its own rules". */
8
4
  export const CUSTOM_VALUE = "custom";
9
5
  /**
10
- * The dropdown value that means "no rules at all".
11
- *
12
- * A word rather than an empty string: the Select reads "" as "nothing chosen"
13
- * and paints its placeholder over the label, so a channel open to everyone
14
- * showed "Select" instead of "Everyone". See [[base-ui-select-empty-value]] —
15
- * the Max Bitrate control in the same dialog already used "default" for this.
6
+ * The dropdown value that means "no rules at all". A word rather than an empty string: the
7
+ * Select reads "" as "nothing chosen" and paints its placeholder over the label.
16
8
  */
17
9
  export const EVERYONE_VALUE = "everyone";
18
10
  /** Look up one cell without scanning the whole list per cell. */
@@ -26,11 +18,8 @@ export function cellState(index, roleId, permission) {
26
18
  return index.get(`${roleId}\u0000${permission}`) ?? "inherit";
27
19
  }
28
20
  /**
29
- * The next state when somebody clicks a cell.
30
- *
31
- * inherit to deny to allow and back to inherit. Deny comes first deliberately:
32
- * taking something away is what people open this to do, and it puts the
33
- * dangerous state one click from neutral rather than two.
21
+ * The next state when somebody clicks a cell: inherit, deny, allow, inherit. Deny first,
22
+ * because taking something away is what people open this to do.
34
23
  */
35
24
  export function nextCellState(current) {
36
25
  if (current === "inherit")
@@ -47,11 +36,8 @@ export function withCell(rules, roleId, permission, state) {
47
36
  return [...without, { roleId, permission, effect: state }];
48
37
  }
49
38
  /**
50
- * Which dropdown value a channel is showing.
51
- *
52
- * The server sends the scope id plus whether it is a template. A scope that is
53
- * not a template is this channel's own, which is Custom; no scope at all is
54
- * Everyone.
39
+ * Which dropdown value a channel is showing. A scope that is not a template is this
40
+ * channel's own, which is Custom; no scope at all is Everyone.
55
41
  */
56
42
  export function scopeChoiceValue(scopeId, isTemplate) {
57
43
  if (!scopeId)
@@ -67,12 +53,8 @@ export function scopeChoiceFromValue(value) {
67
53
  return { kind: "template", templateId: value };
68
54
  }
69
55
  /**
70
- * The payload for `server:channels:scope:set`.
71
- *
72
- * Custom sends its rules; the other two send none. Templates deliberately do
73
- * not carry rules here — editing a template from a channel would change every
74
- * other channel using it, which is the opposite of what somebody expects from
75
- * a screen titled with one channel's name.
56
+ * The payload for `server:channels:scope:set`. Templates deliberately do not carry rules:
57
+ * editing one from a channel would change every other channel using it.
76
58
  */
77
59
  export function scopeSetPayload(choice, rules) {
78
60
  if (choice.kind === "custom")
@@ -82,11 +64,8 @@ export function scopeSetPayload(choice, rules) {
82
64
  return { templateId: null };
83
65
  }
84
66
  /**
85
- * The dropdown options: Everyone, then each template, then Custom.
86
- *
87
- * Custom last because it is the escape hatch. Somebody scanning the list should
88
- * meet the shared answers first — the whole point of templates is that reaching
89
- * for Custom is the uncommon choice.
67
+ * The dropdown options: Everyone, then each template, then Custom. Custom last, because it
68
+ * is the escape hatch and reaching for it is the uncommon choice.
90
69
  */
91
70
  export function scopeOptions(templates) {
92
71
  return [
@@ -98,12 +77,8 @@ export function scopeOptions(templates) {
98
77
  ];
99
78
  }
100
79
  /**
101
- * A one-line summary of what a set of rules does, for the note under the
102
- * dropdown.
103
- *
104
- * Reading is called out on its own because it is the rule with a different
105
- * consequence: a role denied `read_messages` is not shown a locked channel,
106
- * it is shown nothing at all, and somebody setting that deserves to be told.
80
+ * A one-line summary of what a set of rules does. Reading is called out on its own: a role
81
+ * denied `read_messages` is shown nothing at all rather than a locked channel.
107
82
  */
108
83
  export function describeRules(rules, roleNames) {
109
84
  if (rules.length === 0)
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Gems, metals, animals, weather, sky, and a few playful roles.
3
- *
4
- * Long enough that a room of thirty rarely doubles up, which is the whole
5
- * reason for the size — two people called Ruby in a member list is exactly the
6
- * confusion the old shared placeholder caused.
2
+ * Gems, metals, animals, weather, sky, and a few playful roles. Long enough that a room of
3
+ * thirty rarely doubles up, which is the whole reason for the size.
7
4
  */
8
5
  export declare const NAME_POOL: readonly ["Ruby", "Emerald", "Diamond", "Sapphire", "Topaz", "Opal", "Amber", "Jade", "Onyx", "Pearl", "Quartz", "Garnet", "Amethyst", "Turquoise", "Obsidian", "Marble", "Granite", "Flint", "Slate", "Crystal", "Gold", "Silver", "Copper", "Bronze", "Iron", "Steel", "Cobalt", "Pewter", "Platinum", "Titanium", "Brass", "Nickel", "Shark", "Turtle", "Otter", "Falcon", "Heron", "Badger", "Lynx", "Bison", "Walrus", "Puffin", "Osprey", "Marten", "Ibex", "Tapir", "Gecko", "Manta", "Narwhal", "Pelican", "Raven", "Magpie", "Sparrow", "Kestrel", "Weasel", "Beaver", "Moose", "Elk", "Stoat", "Hedgehog", "Mongoose", "Meerkat", "Albatross", "Barnacle", "Urchin", "Cuttlefish", "Seal", "Puma", "Ocelot", "Caribou", "Wombat", "Numbat", "Quokka", "Capybara", "Sheriff", "Bandit", "Captain", "Ranger", "Scout", "Pilot", "Sailor", "Miner", "Baker", "Cooper", "Mason", "Tinker", "Herald", "Envoy", "Courier", "Gardener", "Lookout", "Skipper", "Wrangler", "Drifter", "Comet", "Nebula", "Quasar", "Aurora", "Meteor", "Eclipse", "Zenith", "Thunder", "Cyclone", "Monsoon", "Blizzard", "Drizzle", "Gale", "Frost", "Ember", "Cinder", "Vapour", "Mistral", "Zephyr", "Squall", "Canyon", "Fjord", "Glacier", "Delta", "Mesa", "Tundra", "Prairie", "Dune", "Reef", "Atoll", "Summit", "Ridge", "Hollow", "Meadow", "Thicket", "Bramble", "Harbour", "Lagoon", "Cove", "Cascade", "Cedar", "Juniper", "Willow", "Birch", "Aspen", "Alder", "Hazel", "Bracken", "Clover", "Fennel", "Sorrel", "Nettle", "Thistle", "Heather", "Saffron", "Cardamom", "Chicory", "Marigold"];
9
6
  /**
10
- * One name from the pool.
11
- *
12
- * `Math.random` on purpose. This picks something to be called, not a secret —
13
- * the identity is a keypair, and the name has never been part of it. Reaching
14
- * for the crypto RNG here would suggest otherwise.
7
+ * One name from the pool. `Math.random` on purpose: this picks something to be called, not a
8
+ * secret, and the crypto RNG here would suggest otherwise.
15
9
  */
16
10
  export declare function pickRandomName(): string;
@@ -1,13 +1,8 @@
1
- /* Adding to this list: only nouns, only English, nothing that reads as a real
2
- person's name. These go in front of strangers and are read aloud in voice,
3
- and whoever gets one did not choose it. "Marcus" looks like a claim about
4
- who somebody is; "Bandit" looks like the placeholder it is. */
1
+ /* Adding to this list: only nouns, only English, nothing that reads as a real person's name.
2
+ Whoever gets one did not choose it, and "Marcus" looks like a claim. */
5
3
  /**
6
- * Gems, metals, animals, weather, sky, and a few playful roles.
7
- *
8
- * Long enough that a room of thirty rarely doubles up, which is the whole
9
- * reason for the size — two people called Ruby in a member list is exactly the
10
- * confusion the old shared placeholder caused.
4
+ * Gems, metals, animals, weather, sky, and a few playful roles. Long enough that a room of
5
+ * thirty rarely doubles up, which is the whole reason for the size.
11
6
  */
12
7
  export const NAME_POOL = [
13
8
  // Gems and minerals
@@ -42,11 +37,8 @@ export const NAME_POOL = [
42
37
  "Cardamom", "Chicory", "Marigold",
43
38
  ];
44
39
  /**
45
- * One name from the pool.
46
- *
47
- * `Math.random` on purpose. This picks something to be called, not a secret —
48
- * the identity is a keypair, and the name has never been part of it. Reaching
49
- * for the crypto RNG here would suggest otherwise.
40
+ * One name from the pool. `Math.random` on purpose: this picks something to be called, not a
41
+ * secret, and the crypto RNG here would suggest otherwise.
50
42
  */
51
43
  export function pickRandomName() {
52
44
  return NAME_POOL[Math.floor(Math.random() * NAME_POOL.length)];
@@ -48,11 +48,8 @@ export interface Report {
48
48
  extra?: Record<string, unknown>;
49
49
  }
50
50
  /**
51
- * What an app knows about itself when somebody opens the form.
52
- *
53
- * Passed in rather than read here, so the assembly below stays readable and
54
- * testable: every one of these comes from somewhere that needs a running app —
55
- * the Electron bridge, `expo-constants`, `Platform`, the socket layer.
51
+ * What an app knows about itself when somebody opens the form. Passed in rather than read
52
+ * here: every one of these comes from somewhere that needs a running app.
56
53
  */
57
54
  export interface Diagnostics {
58
55
  version?: string | null;
@@ -90,21 +87,14 @@ export interface Diagnostics {
90
87
  logs?: string[];
91
88
  }
92
89
  /**
93
- * The service truncates, but an app should not send a novel either.
94
- *
95
- * Generous rather than tight: somebody describing a bug properly is the good
96
- * case, and cutting them off at a tweet is how you get "it broke" instead. The
97
- * desktop's 8000 wins over the phone's 4000 on exactly that argument, which
98
- * both files made and only one of them followed.
90
+ * The service truncates, but an app should not send a novel either. Generous rather than
91
+ * tight: cutting somebody off at a tweet is how you get "it broke".
99
92
  */
100
93
  export declare const MESSAGE_MAX = 8000;
101
94
  export declare const TITLE_MAX = 120;
102
95
  /**
103
- * Assemble what gets sent.
104
- *
105
- * `type` and `message` always; every diagnostic only if it is actually known.
106
- * An empty `device` object says "this app does not collect device
107
- * information", which is a different and wronger claim than leaving it off.
96
+ * Assemble what gets sent: `type` and `message` always, and a diagnostic only if it is
97
+ * known. An empty `device` claims the app collects none, which is wronger than leaving it off.
108
98
  */
109
99
  export declare function buildReport(type: ReportType, input: {
110
100
  message: string;
@@ -1,14 +1,8 @@
1
- /* `POST /v1/reports`. Only `type` and `message` are required; the service
2
- truncates or drops a bad field rather than rejecting the report. So nothing
3
- here throws, and a diagnostic an app cannot work out is left off rather than
4
- guessed — a wrong Electron version is worse than no Electron version. */
1
+ /* `POST /v1/reports`. Only `type` and `message` are required and the service truncates
2
+ rather than rejecting, so nothing throws and an unknown diagnostic is left off. */
5
3
  /**
6
- * The service truncates, but an app should not send a novel either.
7
- *
8
- * Generous rather than tight: somebody describing a bug properly is the good
9
- * case, and cutting them off at a tweet is how you get "it broke" instead. The
10
- * desktop's 8000 wins over the phone's 4000 on exactly that argument, which
11
- * both files made and only one of them followed.
4
+ * The service truncates, but an app should not send a novel either. Generous rather than
5
+ * tight: cutting somebody off at a tweet is how you get "it broke".
12
6
  */
13
7
  export const MESSAGE_MAX = 8000;
14
8
  export const TITLE_MAX = 120;
@@ -37,18 +31,12 @@ function count(value) {
37
31
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
38
32
  }
39
33
  /**
40
- * Assemble what gets sent.
41
- *
42
- * `type` and `message` always; every diagnostic only if it is actually known.
43
- * An empty `device` object says "this app does not collect device
44
- * information", which is a different and wronger claim than leaving it off.
34
+ * Assemble what gets sent: `type` and `message` always, and a diagnostic only if it is
35
+ * known. An empty `device` claims the app collects none, which is wronger than leaving it off.
45
36
  */
46
37
  export function buildReport(type, input, diagnostics = {}) {
47
- /* The embedded server is two fields on the wire rather than one, because
48
- * "running its own server" and "which version that server is" answer
49
- * different questions and the second is often the one that matters. Neither
50
- * has a column on the service, so they go in `extra`, which is what it is
51
- * for. Only the desktop can run one, and the phone simply never sets them. */
38
+ /* The embedded server is two fields on the wire, because "running one" and "which version"
39
+ * answer different questions. Neither has a column, so they go in `extra`. */
52
40
  const extra = some({
53
41
  embeddedServer: bool(diagnostics.embeddedServer),
54
42
  embeddedServerVersion: str(diagnostics.embeddedServerVersion),
@@ -9,3 +9,11 @@ export interface ServerInput {
9
9
  export declare function parseServerInput(input: string, opts?: {
10
10
  defaultLegacyHost?: string;
11
11
  }): ServerInput;
12
+ /** The link that opens an invite in whichever app somebody has. Leave out the code for a
13
+ server anyone can join. */
14
+ export declare function inviteLink(host: string, code?: string): string;
15
+ /**
16
+ * Whether an address works for somebody on another network. Loopback, private and
17
+ * link-local ranges, CGNAT, and names like `nas` or `box.local` do not.
18
+ */
19
+ export declare function isPublicHost(host: string): boolean;
@@ -14,16 +14,12 @@ export function normalizeCode(input) {
14
14
  .toLowerCase();
15
15
  }
16
16
  /**
17
- * The default host for a legacy `/invite/<code>` link.
18
- *
19
- * Those links carry no host, and the only client ever served from a path like
20
- * that is the hosted one.
17
+ * The default host for a legacy `/invite/<code>` link. Those carry no host, and the only
18
+ * client ever served from a path like that is the hosted one.
21
19
  */
22
20
  const DEFAULT_LEGACY_HOST = "app.gryt.chat";
23
- /* Three shapes: a full invite link, a legacy /invite/<code>, and a plain
24
- address. `normalizeHost` alone is wrong for the first two — it returns the
25
- link's host, so you would join gryt.chat instead of the server named in the
26
- query. Anything that does not parse as a link falls through to an address. */
21
+ /* Four shapes: an invite link with a code, one with only a host, a legacy /invite/<code>,
22
+ and a plain address. `normalizeHost` alone returns the link's host, so you'd join gryt.chat. */
27
23
  export function parseServerInput(input, opts) {
28
24
  const raw = String(input || "").trim();
29
25
  if (!raw)
@@ -35,9 +31,8 @@ export function parseServerInput(input, opts) {
35
31
  try {
36
32
  const url = new URL(raw);
37
33
  const path = url.pathname || "/";
38
- // gryt://invite?host=…&code=… puts "invite" in the authority rather than
39
- // the path, because the scheme is not one the URL parser treats as
40
- // special. Both spellings mean the same thing.
34
+ // gryt://invite?host=…&code=… puts "invite" in the authority rather than the path,
35
+ // because the scheme is not one the URL parser treats as special. Both mean the same.
41
36
  const isInvite = path.startsWith("/invite") || url.hostname === "invite";
42
37
  if (isInvite) {
43
38
  const host = normalizeHost(url.searchParams.get("host") || "");
@@ -48,6 +43,9 @@ export function parseServerInput(input, opts) {
48
43
  if (parts[0] === "invite" && parts[1]) {
49
44
  return { host: legacyHost, code: normalizeCode(parts[1]) };
50
45
  }
46
+ // A server anyone can join needs no code, so its link names only the host.
47
+ if (host)
48
+ return { host, code: "" };
51
49
  }
52
50
  }
53
51
  catch {
@@ -56,3 +54,66 @@ export function parseServerInput(input, opts) {
56
54
  }
57
55
  return { host: normalizeHost(raw), code: "" };
58
56
  }
57
+ /** The link that opens an invite in whichever app somebody has. Leave out the code for a
58
+ server anyone can join. */
59
+ export function inviteLink(host, code) {
60
+ const cleanCode = normalizeCode(code ?? "");
61
+ const query = `host=${encodeURIComponent(normalizeHost(host))}`;
62
+ return `https://gryt.chat/invite?${query}${cleanCode ? `&code=${encodeURIComponent(cleanCode)}` : ""}`;
63
+ }
64
+ /** Names that only mean something on one network. A bare name with no dot is one too. */
65
+ const LOCAL_SUFFIXES = ["localhost", "local", "localdomain", "lan", "home", "internal", "home.arpa"];
66
+ /** Lowercased, with any port, brackets and trailing dot removed. */
67
+ function bareHostname(host) {
68
+ const trimmed = normalizeHost(host).toLowerCase();
69
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(trimmed);
70
+ if (bracketed)
71
+ return bracketed[1];
72
+ const colons = (trimmed.match(/:/g) || []).length;
73
+ const withoutPort = colons === 1 ? trimmed.replace(/:\d*$/, "") : trimmed;
74
+ return withoutPort.replace(/\.$/, "");
75
+ }
76
+ function ipv4Octets(name) {
77
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(name))
78
+ return null;
79
+ const octets = name.split(".").map(Number);
80
+ return octets.every((n) => n <= 255) ? octets : null;
81
+ }
82
+ function isPublicIpv4([a, b]) {
83
+ if (a === 0 || a === 10 || a === 127 || a >= 224)
84
+ return false;
85
+ if (a === 100 && b >= 64 && b <= 127)
86
+ return false; // CGNAT, and Tailscale
87
+ if (a === 169 && b === 254)
88
+ return false;
89
+ if (a === 172 && b >= 16 && b <= 31)
90
+ return false;
91
+ return !(a === 192 && b === 168);
92
+ }
93
+ /**
94
+ * Whether an address works for somebody on another network. Loopback, private and
95
+ * link-local ranges, CGNAT, and names like `nas` or `box.local` do not.
96
+ */
97
+ export function isPublicHost(host) {
98
+ const name = bareHostname(host);
99
+ if (!name)
100
+ return false;
101
+ const v4 = ipv4Octets(name);
102
+ if (v4)
103
+ return isPublicIpv4(v4);
104
+ if (name.includes(":")) {
105
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(name);
106
+ if (mapped) {
107
+ const octets = ipv4Octets(mapped[1]);
108
+ return !!octets && isPublicIpv4(octets);
109
+ }
110
+ if (name === "::" || name === "::1")
111
+ return false;
112
+ // fc00::/7 is private, fe80::/10 is link-local.
113
+ return !/^f[cd]/.test(name) && !/^fe[89ab]/.test(name);
114
+ }
115
+ // No top-level domain is all digits, so `999.1.1.1` is a broken address rather than a name.
116
+ if (!name.includes(".") || /\.\d+$/.test(name))
117
+ return false;
118
+ return !LOCAL_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`));
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -22,6 +22,7 @@
22
22
  "scripts": {
23
23
  "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.mjs",
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "test:comment-length": "node scripts/check-comment-length.mjs",
25
26
  "test": "node --test \"src/**/*.test.ts\"",
26
27
  "check-surface": "node scripts/check-public-surface.mjs",
27
28
  "prepublishOnly": "npm run build && npm run test && npm run check-surface"