@gryt/core 0.1.0 → 0.3.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 +74 -34
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/permissions/channelRules.d.ts +110 -0
- package/dist/permissions/channelRules.js +140 -0
- package/dist/profile/randomName.d.ts +35 -0
- package/dist/profile/randomName.js +68 -0
- package/dist/servers/address.d.ts +43 -0
- package/dist/servers/address.js +84 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Gryt-chat/client/main/public/logo.svg" width="80" alt="Gryt logo" />
|
|
3
|
+
<h1>@gryt/core</h1>
|
|
4
|
+
<p>The application logic from <a href="https://github.com/Gryt-chat/gryt">Gryt</a> that the desktop and the phone both run.<br />One implementation of the things they were each doing separately.</p>
|
|
5
|
+
</div>
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
7
|
+
<br />
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @gryt/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The desktop app and the mobile app carried a copy each of the same decisions.
|
|
14
|
+
Some were real copies, some were the same idea written twice, and nothing told
|
|
15
|
+
the two apart until somebody diffed them by hand. `report.ts` had the same seven
|
|
16
|
+
exports on both sides and a different `MESSAGE_MAX` on each, 8000 against 4000,
|
|
17
|
+
with nothing recording why.
|
|
18
|
+
|
|
19
|
+
They run this, not two ports of it. Both apps should pin the same version: a
|
|
20
|
+
shared package on two versions is two implementations again.
|
|
5
21
|
|
|
6
22
|
## What goes in here
|
|
7
23
|
|
|
@@ -14,38 +30,42 @@ Two questions, and a module needs both answers to be yes:
|
|
|
14
30
|
`window` and `localStorage` fail to typecheck rather than working in one app and
|
|
15
31
|
breaking in the other. The second is a judgement call, and the surface guard is
|
|
16
32
|
what stops it drifting: `scripts/check-public-surface.mjs` fails when an export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
## What
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
goes missing **and** when one appears that nobody listed.
|
|
34
|
+
|
|
35
|
+
## What it is
|
|
36
|
+
|
|
37
|
+
- **`reports`** — the shape [`Gryt-chat/reports`](https://github.com/Gryt-chat/reports)
|
|
38
|
+
takes, and how an app fills it in. `Diagnostics` is the union of what each
|
|
39
|
+
platform can find out, so a renderer sends its Chrome build, a phone sends
|
|
40
|
+
whether it's a simulator, and a field neither fills can be seen to be dead.
|
|
41
|
+
- **`links`** — which site a URL belongs to, what colour to draw its card, what
|
|
42
|
+
line to read out of its path, and which of four shapes a preview earns. 73
|
|
43
|
+
sites, seven of which also read a detail from the path.
|
|
44
|
+
- **`profile`** — the pool of placeholder names a person gets before they pick
|
|
45
|
+
one. Both apps handed these out and both files said to keep the other in step
|
|
46
|
+
by hand.
|
|
47
|
+
- **`permissions`** — the channel scope matrix: rules in, grid out, and back.
|
|
48
|
+
The two apps keyed their cell map differently, a NUL on the desktop and a
|
|
49
|
+
space on the phone, which is a key one of them could collide on.
|
|
50
|
+
|
|
51
|
+
## What deliberately isn't
|
|
52
|
+
|
|
53
|
+
Anything needing a platform. Fetching is the clearest case: the desktop and the
|
|
54
|
+
phone reach a server differently, so this decides what a link preview *means*
|
|
55
|
+
and each app goes and gets it.
|
|
24
56
|
|
|
25
57
|
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
|
|
27
|
-
colours and the path rules
|
|
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
|
|
58
|
+
an SVG path or a favicon on the phone, so the package owns the hostnames, the
|
|
59
|
+
colours and the path rules, and each app maps a provider `id` to its own icon.
|
|
34
60
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
the desktop and 4000 on the phone, and nothing recorded a reason.
|
|
61
|
+
[`@gryt/voice`](https://github.com/Gryt-chat/voice) splits web and native behind
|
|
62
|
+
two entry points because a media engine can't avoid the platform. This package
|
|
63
|
+
has no such entry, on purpose. If one becomes necessary, that's a decision to
|
|
64
|
+
make out loud rather than by adding `DOM` to the tsconfig.
|
|
40
65
|
|
|
41
|
-
|
|
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
|
|
66
|
+
## Checks
|
|
46
67
|
|
|
47
68
|
```bash
|
|
48
|
-
npm install
|
|
49
69
|
npm test # node --test, no runner to install
|
|
50
70
|
npm run typecheck
|
|
51
71
|
npm run build # tsc to dist/, then rewrite specifiers for ESM
|
|
@@ -55,9 +75,29 @@ npm run check-surface
|
|
|
55
75
|
`prepublishOnly` runs the build, the tests and the surface guard, so a release
|
|
56
76
|
that would have shipped a missing export fails before it leaves.
|
|
57
77
|
|
|
58
|
-
##
|
|
78
|
+
## Issues
|
|
79
|
+
|
|
80
|
+
Please report bugs and request features in the
|
|
81
|
+
[main Gryt repository](https://github.com/Gryt-chat/gryt/issues).
|
|
82
|
+
|
|
83
|
+
## Sponsors
|
|
84
|
+
|
|
85
|
+
What sponsoring pays for, the tiers, and everyone who has sponsored:
|
|
86
|
+
[gryt.chat/sponsors](https://gryt.chat/sponsors). To sponsor:
|
|
87
|
+
[GitHub Sponsors](https://github.com/sponsors/Gryt-chat).
|
|
88
|
+
|
|
89
|
+
The list itself lives in the [Gryt README](https://github.com/Gryt-chat/gryt#sponsors),
|
|
90
|
+
in one place rather than ten, so it cannot fall out of step across repositories.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
[AGPL-3.0](https://github.com/Gryt-chat/gryt/blob/main/LICENSE) — Part of [Gryt](https://github.com/Gryt-chat/gryt)
|
|
95
|
+
|
|
96
|
+
[`@gryt/ui`](https://github.com/Gryt-chat/ui) is the exception in this org, and
|
|
97
|
+
deliberately so: it's generic components with nothing of Gryt in them, and
|
|
98
|
+
copyleft there would rule out most of the people who might use it.
|
|
59
99
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
This isn't that. It's the decisions the Gryt apps make, which is the product
|
|
101
|
+
rather than scaffolding around it. It's still yours to embed, self-host and
|
|
102
|
+
modify. The licence only bites for running a modified version as a closed
|
|
103
|
+
service.
|
package/dist/index.d.ts
CHANGED
|
@@ -14,3 +14,6 @@
|
|
|
14
14
|
export { buildReport, describeAttached, MESSAGE_MAX, TITLE_MAX, type Diagnostics, type Report, type ReportType, } from "./reports/report.js";
|
|
15
15
|
export { getAccentColor, getLinkProvider, getProviderDetail, hostnameOf, LINK_PROVIDERS, type LinkProvider, } from "./links/providers.js";
|
|
16
16
|
export { describePreviewFailure, extractUrls, getCardSubtitle, getLinkCardLayout, type LinkCardLayout, type LinkPreviewData, } from "./links/preview.js";
|
|
17
|
+
export { normalizeCode, normalizeHost, parseServerInput, type ServerInput, } from "./servers/address.js";
|
|
18
|
+
export { NAME_POOL, pickRandomName } from "./profile/randomName.js";
|
|
19
|
+
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";
|
package/dist/index.js
CHANGED
|
@@ -14,3 +14,6 @@
|
|
|
14
14
|
export { buildReport, describeAttached, MESSAGE_MAX, TITLE_MAX, } from "./reports/report.js";
|
|
15
15
|
export { getAccentColor, getLinkProvider, getProviderDetail, hostnameOf, LINK_PROVIDERS, } from "./links/providers.js";
|
|
16
16
|
export { describePreviewFailure, extractUrls, getCardSubtitle, getLinkCardLayout, } from "./links/preview.js";
|
|
17
|
+
export { normalizeCode, normalizeHost, parseServerInput, } from "./servers/address.js";
|
|
18
|
+
export { NAME_POOL, pickRandomName } from "./profile/randomName.js";
|
|
19
|
+
export { cellState, CUSTOM_VALUE, describeRules, EVERYONE_VALUE, indexRules, nextCellState, scopeChoiceFromValue, scopeChoiceValue, scopeOptions, scopeSetPayload, withCell, } from "./permissions/channelRules.js";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules on a channel scope, as a matrix and back again.
|
|
3
|
+
*
|
|
4
|
+
* Pure and on its own like `serverOrder.ts`, because everything that can go
|
|
5
|
+
* wrong here is in the mapping between what the server stores and what the grid
|
|
6
|
+
* shows, and that is worth exercising without React.
|
|
7
|
+
*
|
|
8
|
+
* The server stores one row per thing a scope changes. Inherit is the absence
|
|
9
|
+
* of a row, so the matrix has three states per cell and only two of them are
|
|
10
|
+
* ever written.
|
|
11
|
+
*
|
|
12
|
+
* ## The cell key
|
|
13
|
+
*
|
|
14
|
+
* `indexRules` builds one map keyed by role and permission together, and the
|
|
15
|
+
* separator between them matters. The desktop used a NUL and the phone used a
|
|
16
|
+
* space, which is two implementations of a key and one of them weaker: a role
|
|
17
|
+
* id or a permission containing a space would collide. Neither does today, so
|
|
18
|
+
* nothing was broken, but the two had drifted with nothing to notice it.
|
|
19
|
+
*
|
|
20
|
+
* NUL wins, because it cannot appear in either half. It is written as a `\u0000` escape
|
|
21
|
+
* rather than as a raw byte — the desktop's copy held two literal NULs, which
|
|
22
|
+
* made git treat the whole file as binary and stop showing diffs for it.
|
|
23
|
+
*/
|
|
24
|
+
export type RuleEffect = "allow" | "deny";
|
|
25
|
+
/** What one cell in the matrix is showing. */
|
|
26
|
+
export type CellState = "inherit" | RuleEffect;
|
|
27
|
+
export interface ChannelRule {
|
|
28
|
+
roleId: string;
|
|
29
|
+
permission: string;
|
|
30
|
+
effect: RuleEffect;
|
|
31
|
+
}
|
|
32
|
+
/** The choice in the channel's dropdown, before any matrix is drawn. */
|
|
33
|
+
export type ScopeChoice = {
|
|
34
|
+
kind: "everyone";
|
|
35
|
+
} | {
|
|
36
|
+
kind: "template";
|
|
37
|
+
templateId: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "custom";
|
|
40
|
+
};
|
|
41
|
+
/** The dropdown value that means "this channel has its own rules". */
|
|
42
|
+
export declare const CUSTOM_VALUE = "custom";
|
|
43
|
+
/**
|
|
44
|
+
* The dropdown value that means "no rules at all".
|
|
45
|
+
*
|
|
46
|
+
* A word rather than an empty string: the Select reads "" as "nothing chosen"
|
|
47
|
+
* and paints its placeholder over the label, so a channel open to everyone
|
|
48
|
+
* showed "Select" instead of "Everyone". See [[base-ui-select-empty-value]] —
|
|
49
|
+
* the Max Bitrate control in the same dialog already used "default" for this.
|
|
50
|
+
*/
|
|
51
|
+
export declare const EVERYONE_VALUE = "everyone";
|
|
52
|
+
/** Look up one cell without scanning the whole list per cell. */
|
|
53
|
+
export declare function indexRules(rules: ChannelRule[]): Map<string, RuleEffect>;
|
|
54
|
+
export declare function cellState(index: Map<string, RuleEffect>, roleId: string, permission: string): CellState;
|
|
55
|
+
/**
|
|
56
|
+
* The next state when somebody clicks a cell.
|
|
57
|
+
*
|
|
58
|
+
* inherit to deny to allow and back to inherit. Deny comes first deliberately:
|
|
59
|
+
* taking something away is what people open this to do, and it puts the
|
|
60
|
+
* dangerous state one click from neutral rather than two.
|
|
61
|
+
*/
|
|
62
|
+
export declare function nextCellState(current: CellState): CellState;
|
|
63
|
+
/** Set one cell, dropping the row entirely when it goes back to inherit. */
|
|
64
|
+
export declare function withCell(rules: ChannelRule[], roleId: string, permission: string, state: CellState): ChannelRule[];
|
|
65
|
+
/**
|
|
66
|
+
* Which dropdown value a channel is showing.
|
|
67
|
+
*
|
|
68
|
+
* The server sends the scope id plus whether it is a template. A scope that is
|
|
69
|
+
* not a template is this channel's own, which is Custom; no scope at all is
|
|
70
|
+
* Everyone.
|
|
71
|
+
*/
|
|
72
|
+
export declare function scopeChoiceValue(scopeId: string | null, isTemplate: boolean): string;
|
|
73
|
+
/** Turn the dropdown value back into what the server event wants. */
|
|
74
|
+
export declare function scopeChoiceFromValue(value: string): ScopeChoice;
|
|
75
|
+
/**
|
|
76
|
+
* The payload for `server:channels:scope:set`.
|
|
77
|
+
*
|
|
78
|
+
* Custom sends its rules; the other two send none. Templates deliberately do
|
|
79
|
+
* not carry rules here — editing a template from a channel would change every
|
|
80
|
+
* other channel using it, which is the opposite of what somebody expects from
|
|
81
|
+
* a screen titled with one channel's name.
|
|
82
|
+
*/
|
|
83
|
+
export declare function scopeSetPayload(choice: ScopeChoice, rules: ChannelRule[]): {
|
|
84
|
+
templateId?: string | null;
|
|
85
|
+
custom?: boolean;
|
|
86
|
+
rules?: ChannelRule[];
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The dropdown options: Everyone, then each template, then Custom.
|
|
90
|
+
*
|
|
91
|
+
* Custom last because it is the escape hatch. Somebody scanning the list should
|
|
92
|
+
* meet the shared answers first — the whole point of templates is that reaching
|
|
93
|
+
* for Custom is the uncommon choice.
|
|
94
|
+
*/
|
|
95
|
+
export declare function scopeOptions(templates: {
|
|
96
|
+
id: string;
|
|
97
|
+
name: string | null;
|
|
98
|
+
}[]): {
|
|
99
|
+
label: string;
|
|
100
|
+
value: string;
|
|
101
|
+
}[];
|
|
102
|
+
/**
|
|
103
|
+
* A one-line summary of what a set of rules does, for the note under the
|
|
104
|
+
* dropdown.
|
|
105
|
+
*
|
|
106
|
+
* Reading is called out on its own because it is the rule with a different
|
|
107
|
+
* consequence: a role denied `read_messages` is not shown a locked channel,
|
|
108
|
+
* it is shown nothing at all, and somebody setting that deserves to be told.
|
|
109
|
+
*/
|
|
110
|
+
export declare function describeRules(rules: ChannelRule[], roleNames: Map<string, string>): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules on a channel scope, as a matrix and back again.
|
|
3
|
+
*
|
|
4
|
+
* Pure and on its own like `serverOrder.ts`, because everything that can go
|
|
5
|
+
* wrong here is in the mapping between what the server stores and what the grid
|
|
6
|
+
* shows, and that is worth exercising without React.
|
|
7
|
+
*
|
|
8
|
+
* The server stores one row per thing a scope changes. Inherit is the absence
|
|
9
|
+
* of a row, so the matrix has three states per cell and only two of them are
|
|
10
|
+
* ever written.
|
|
11
|
+
*
|
|
12
|
+
* ## The cell key
|
|
13
|
+
*
|
|
14
|
+
* `indexRules` builds one map keyed by role and permission together, and the
|
|
15
|
+
* separator between them matters. The desktop used a NUL and the phone used a
|
|
16
|
+
* space, which is two implementations of a key and one of them weaker: a role
|
|
17
|
+
* id or a permission containing a space would collide. Neither does today, so
|
|
18
|
+
* nothing was broken, but the two had drifted with nothing to notice it.
|
|
19
|
+
*
|
|
20
|
+
* NUL wins, because it cannot appear in either half. It is written as a `\u0000` escape
|
|
21
|
+
* rather than as a raw byte — the desktop's copy held two literal NULs, which
|
|
22
|
+
* made git treat the whole file as binary and stop showing diffs for it.
|
|
23
|
+
*/
|
|
24
|
+
/** The dropdown value that means "this channel has its own rules". */
|
|
25
|
+
export const CUSTOM_VALUE = "custom";
|
|
26
|
+
/**
|
|
27
|
+
* The dropdown value that means "no rules at all".
|
|
28
|
+
*
|
|
29
|
+
* A word rather than an empty string: the Select reads "" as "nothing chosen"
|
|
30
|
+
* and paints its placeholder over the label, so a channel open to everyone
|
|
31
|
+
* showed "Select" instead of "Everyone". See [[base-ui-select-empty-value]] —
|
|
32
|
+
* the Max Bitrate control in the same dialog already used "default" for this.
|
|
33
|
+
*/
|
|
34
|
+
export const EVERYONE_VALUE = "everyone";
|
|
35
|
+
/** Look up one cell without scanning the whole list per cell. */
|
|
36
|
+
export function indexRules(rules) {
|
|
37
|
+
const byCell = new Map();
|
|
38
|
+
for (const rule of rules)
|
|
39
|
+
byCell.set(`${rule.roleId}\u0000${rule.permission}`, rule.effect);
|
|
40
|
+
return byCell;
|
|
41
|
+
}
|
|
42
|
+
export function cellState(index, roleId, permission) {
|
|
43
|
+
return index.get(`${roleId}\u0000${permission}`) ?? "inherit";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The next state when somebody clicks a cell.
|
|
47
|
+
*
|
|
48
|
+
* inherit to deny to allow and back to inherit. Deny comes first deliberately:
|
|
49
|
+
* taking something away is what people open this to do, and it puts the
|
|
50
|
+
* dangerous state one click from neutral rather than two.
|
|
51
|
+
*/
|
|
52
|
+
export function nextCellState(current) {
|
|
53
|
+
if (current === "inherit")
|
|
54
|
+
return "deny";
|
|
55
|
+
if (current === "deny")
|
|
56
|
+
return "allow";
|
|
57
|
+
return "inherit";
|
|
58
|
+
}
|
|
59
|
+
/** Set one cell, dropping the row entirely when it goes back to inherit. */
|
|
60
|
+
export function withCell(rules, roleId, permission, state) {
|
|
61
|
+
const without = rules.filter((r) => !(r.roleId === roleId && r.permission === permission));
|
|
62
|
+
if (state === "inherit")
|
|
63
|
+
return without;
|
|
64
|
+
return [...without, { roleId, permission, effect: state }];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Which dropdown value a channel is showing.
|
|
68
|
+
*
|
|
69
|
+
* The server sends the scope id plus whether it is a template. A scope that is
|
|
70
|
+
* not a template is this channel's own, which is Custom; no scope at all is
|
|
71
|
+
* Everyone.
|
|
72
|
+
*/
|
|
73
|
+
export function scopeChoiceValue(scopeId, isTemplate) {
|
|
74
|
+
if (!scopeId)
|
|
75
|
+
return EVERYONE_VALUE;
|
|
76
|
+
return isTemplate ? scopeId : CUSTOM_VALUE;
|
|
77
|
+
}
|
|
78
|
+
/** Turn the dropdown value back into what the server event wants. */
|
|
79
|
+
export function scopeChoiceFromValue(value) {
|
|
80
|
+
if (value === CUSTOM_VALUE)
|
|
81
|
+
return { kind: "custom" };
|
|
82
|
+
if (value === EVERYONE_VALUE || value === "")
|
|
83
|
+
return { kind: "everyone" };
|
|
84
|
+
return { kind: "template", templateId: value };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The payload for `server:channels:scope:set`.
|
|
88
|
+
*
|
|
89
|
+
* Custom sends its rules; the other two send none. Templates deliberately do
|
|
90
|
+
* not carry rules here — editing a template from a channel would change every
|
|
91
|
+
* other channel using it, which is the opposite of what somebody expects from
|
|
92
|
+
* a screen titled with one channel's name.
|
|
93
|
+
*/
|
|
94
|
+
export function scopeSetPayload(choice, rules) {
|
|
95
|
+
if (choice.kind === "custom")
|
|
96
|
+
return { custom: true, rules };
|
|
97
|
+
if (choice.kind === "template")
|
|
98
|
+
return { templateId: choice.templateId };
|
|
99
|
+
return { templateId: null };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The dropdown options: Everyone, then each template, then Custom.
|
|
103
|
+
*
|
|
104
|
+
* Custom last because it is the escape hatch. Somebody scanning the list should
|
|
105
|
+
* meet the shared answers first — the whole point of templates is that reaching
|
|
106
|
+
* for Custom is the uncommon choice.
|
|
107
|
+
*/
|
|
108
|
+
export function scopeOptions(templates) {
|
|
109
|
+
return [
|
|
110
|
+
{ label: "Everyone", value: EVERYONE_VALUE },
|
|
111
|
+
...templates
|
|
112
|
+
.filter((t) => t.name)
|
|
113
|
+
.map((t) => ({ label: t.name, value: t.id })),
|
|
114
|
+
{ label: "Custom…", value: CUSTOM_VALUE },
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A one-line summary of what a set of rules does, for the note under the
|
|
119
|
+
* dropdown.
|
|
120
|
+
*
|
|
121
|
+
* Reading is called out on its own because it is the rule with a different
|
|
122
|
+
* consequence: a role denied `read_messages` is not shown a locked channel,
|
|
123
|
+
* it is shown nothing at all, and somebody setting that deserves to be told.
|
|
124
|
+
*/
|
|
125
|
+
export function describeRules(rules, roleNames) {
|
|
126
|
+
if (rules.length === 0)
|
|
127
|
+
return "Everyone on the server can see and use this channel.";
|
|
128
|
+
const hidden = rules
|
|
129
|
+
.filter((r) => r.permission === "read_messages" && r.effect === "deny")
|
|
130
|
+
.map((r) => roleNames.get(r.roleId) ?? r.roleId);
|
|
131
|
+
const others = rules.filter((r) => r.permission !== "read_messages").length;
|
|
132
|
+
if (hidden.length === 0) {
|
|
133
|
+
return `${others} change${others === 1 ? "" : "s"} to what roles can do here.`;
|
|
134
|
+
}
|
|
135
|
+
const list = hidden.length === 1
|
|
136
|
+
? hidden[0]
|
|
137
|
+
: `${hidden.slice(0, -1).join(", ")} and ${hidden[hidden.length - 1]}`;
|
|
138
|
+
const rest = others > 0 ? `, and ${others} other change${others === 1 ? "" : "s"}` : "";
|
|
139
|
+
return `${list} will not see this channel at all${rest}.`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A name for an install or a phone that has not been given one.
|
|
3
|
+
*
|
|
4
|
+
* The alternative was "Unknown" on the desktop and "You" on the phone, and it
|
|
5
|
+
* was wrong twice over on both: a guest arrived on every server under the same
|
|
6
|
+
* name, and the generated avatar is seeded on the name, so everybody who had
|
|
7
|
+
* not set one shared a face. A random word fixes both — it is something to be
|
|
8
|
+
* called, and it is different per install. GRYT-846.
|
|
9
|
+
*
|
|
10
|
+
* The list is deliberately dull. These names go in front of strangers on
|
|
11
|
+
* somebody else's server, they are read aloud in voice chat, and whoever gets
|
|
12
|
+
* one did not choose it — so nothing here should be a joke that lands badly, a
|
|
13
|
+
* word that means something else somewhere, or anything a person would mind
|
|
14
|
+
* being called for the ten seconds before they change it.
|
|
15
|
+
*
|
|
16
|
+
* Only nouns, only English, and nothing that reads as a real person's name.
|
|
17
|
+
* A stranger arriving as "Marcus" looks like a claim about who they are;
|
|
18
|
+
* "Bandit" looks like what it is, which is a placeholder.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Gems, metals, animals, weather, sky, and a few playful roles.
|
|
22
|
+
*
|
|
23
|
+
* Long enough that a room of thirty rarely doubles up, which is the whole
|
|
24
|
+
* reason for the size — two people called Ruby in a member list is exactly the
|
|
25
|
+
* confusion the old shared placeholder caused.
|
|
26
|
+
*/
|
|
27
|
+
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"];
|
|
28
|
+
/**
|
|
29
|
+
* One name from the pool.
|
|
30
|
+
*
|
|
31
|
+
* `Math.random` on purpose. This picks something to be called, not a secret —
|
|
32
|
+
* the identity is a keypair, and the name has never been part of it. Reaching
|
|
33
|
+
* for the crypto RNG here would suggest otherwise.
|
|
34
|
+
*/
|
|
35
|
+
export declare function pickRandomName(): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A name for an install or a phone that has not been given one.
|
|
3
|
+
*
|
|
4
|
+
* The alternative was "Unknown" on the desktop and "You" on the phone, and it
|
|
5
|
+
* was wrong twice over on both: a guest arrived on every server under the same
|
|
6
|
+
* name, and the generated avatar is seeded on the name, so everybody who had
|
|
7
|
+
* not set one shared a face. A random word fixes both — it is something to be
|
|
8
|
+
* called, and it is different per install. GRYT-846.
|
|
9
|
+
*
|
|
10
|
+
* The list is deliberately dull. These names go in front of strangers on
|
|
11
|
+
* somebody else's server, they are read aloud in voice chat, and whoever gets
|
|
12
|
+
* one did not choose it — so nothing here should be a joke that lands badly, a
|
|
13
|
+
* word that means something else somewhere, or anything a person would mind
|
|
14
|
+
* being called for the ten seconds before they change it.
|
|
15
|
+
*
|
|
16
|
+
* Only nouns, only English, and nothing that reads as a real person's name.
|
|
17
|
+
* A stranger arriving as "Marcus" looks like a claim about who they are;
|
|
18
|
+
* "Bandit" looks like what it is, which is a placeholder.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Gems, metals, animals, weather, sky, and a few playful roles.
|
|
22
|
+
*
|
|
23
|
+
* Long enough that a room of thirty rarely doubles up, which is the whole
|
|
24
|
+
* reason for the size — two people called Ruby in a member list is exactly the
|
|
25
|
+
* confusion the old shared placeholder caused.
|
|
26
|
+
*/
|
|
27
|
+
export const NAME_POOL = [
|
|
28
|
+
// Gems and minerals
|
|
29
|
+
"Ruby", "Emerald", "Diamond", "Sapphire", "Topaz", "Opal", "Amber", "Jade",
|
|
30
|
+
"Onyx", "Pearl", "Quartz", "Garnet", "Amethyst", "Turquoise", "Obsidian",
|
|
31
|
+
"Marble", "Granite", "Flint", "Slate", "Crystal",
|
|
32
|
+
// Metals
|
|
33
|
+
"Gold", "Silver", "Copper", "Bronze", "Iron", "Steel", "Cobalt", "Pewter",
|
|
34
|
+
"Platinum", "Titanium", "Brass", "Nickel",
|
|
35
|
+
// Animals
|
|
36
|
+
"Shark", "Turtle", "Otter", "Falcon", "Heron", "Badger", "Lynx", "Bison",
|
|
37
|
+
"Walrus", "Puffin", "Osprey", "Marten", "Ibex", "Tapir", "Gecko", "Manta",
|
|
38
|
+
"Narwhal", "Pelican", "Raven", "Magpie", "Sparrow", "Kestrel", "Weasel",
|
|
39
|
+
"Beaver", "Moose", "Elk", "Stoat", "Hedgehog", "Mongoose", "Meerkat",
|
|
40
|
+
"Albatross", "Barnacle", "Urchin", "Cuttlefish", "Seal", "Puma", "Ocelot",
|
|
41
|
+
"Caribou", "Wombat", "Numbat", "Quokka", "Capybara",
|
|
42
|
+
// Roles, playful
|
|
43
|
+
"Sheriff", "Bandit", "Captain", "Ranger", "Scout", "Pilot", "Sailor",
|
|
44
|
+
"Miner", "Baker", "Cooper", "Mason", "Tinker", "Herald", "Envoy", "Courier",
|
|
45
|
+
"Gardener", "Lookout", "Skipper", "Wrangler", "Drifter",
|
|
46
|
+
// Weather and sky
|
|
47
|
+
"Comet", "Nebula", "Quasar", "Aurora", "Meteor", "Eclipse", "Zenith",
|
|
48
|
+
"Thunder", "Cyclone", "Monsoon", "Blizzard", "Drizzle", "Gale", "Frost",
|
|
49
|
+
"Ember", "Cinder", "Vapour", "Mistral", "Zephyr", "Squall",
|
|
50
|
+
// Landscape
|
|
51
|
+
"Canyon", "Fjord", "Glacier", "Delta", "Mesa", "Tundra", "Prairie", "Dune",
|
|
52
|
+
"Reef", "Atoll", "Summit", "Ridge", "Hollow", "Meadow", "Thicket", "Bramble",
|
|
53
|
+
"Harbour", "Lagoon", "Cove", "Cascade",
|
|
54
|
+
// Plants
|
|
55
|
+
"Cedar", "Juniper", "Willow", "Birch", "Aspen", "Alder", "Hazel", "Bracken",
|
|
56
|
+
"Clover", "Fennel", "Sorrel", "Nettle", "Thistle", "Heather", "Saffron",
|
|
57
|
+
"Cardamom", "Chicory", "Marigold",
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* One name from the pool.
|
|
61
|
+
*
|
|
62
|
+
* `Math.random` on purpose. This picks something to be called, not a secret —
|
|
63
|
+
* the identity is a keypair, and the name has never been part of it. Reaching
|
|
64
|
+
* for the crypto RNG here would suggest otherwise.
|
|
65
|
+
*/
|
|
66
|
+
export function pickRandomName() {
|
|
67
|
+
return NAME_POOL[Math.floor(Math.random() * NAME_POOL.length)];
|
|
68
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading whatever somebody pasted into the join field.
|
|
3
|
+
*
|
|
4
|
+
* Both apps had this, byte for byte: `normalizeHost`, `normalizeCode` and all
|
|
5
|
+
* of `parseServerInput`, down to the comments. The phone said so in its own
|
|
6
|
+
* header — a copy, because the desktop's `common` package was never published,
|
|
7
|
+
* written down in GRYT-406 rather than left to be found once the two had
|
|
8
|
+
* drifted.
|
|
9
|
+
*
|
|
10
|
+
* They had not drifted, which is the only comfortable moment to do this. The
|
|
11
|
+
* two apps talk to the same servers, so an address one reads differently is a
|
|
12
|
+
* server the other cannot join, and that failure looks like the server being
|
|
13
|
+
* down rather than like a parser disagreeing.
|
|
14
|
+
*
|
|
15
|
+
* What stayed behind is the scheme memory — `schemeFor`, `rememberScheme` and
|
|
16
|
+
* the rest. Those reach for storage and the two apps store differently, so they
|
|
17
|
+
* fail the test at the top of this package.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeHost(input: string): string;
|
|
20
|
+
export declare function normalizeCode(input: string): string;
|
|
21
|
+
export interface ServerInput {
|
|
22
|
+
/** Empty when nothing usable was in the input. */
|
|
23
|
+
host: string;
|
|
24
|
+
/** Empty for a plain address, which carries no code. */
|
|
25
|
+
code: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Three shapes arrive and they are not the same thing: a full invite link
|
|
29
|
+
* (`https://gryt.chat/invite?host=…&code=…`), a legacy one
|
|
30
|
+
* (`https://app.gryt.chat/invite/<code>`), and a plain address (`gryt.chat`,
|
|
31
|
+
* `192.168.1.5:5001`).
|
|
32
|
+
*
|
|
33
|
+
* `normalizeHost` on its own is wrong for the first two — it returns the
|
|
34
|
+
* *link's* host, which is gryt.chat, and joining that instead of the server
|
|
35
|
+
* named in the query is a confusing failure rather than an obvious one.
|
|
36
|
+
*
|
|
37
|
+
* Anything that does not parse as a link falls through to being an address, so
|
|
38
|
+
* a typo in a URL still gets the address treatment rather than an error about
|
|
39
|
+
* invite formats.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseServerInput(input: string, opts?: {
|
|
42
|
+
defaultLegacyHost?: string;
|
|
43
|
+
}): ServerInput;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading whatever somebody pasted into the join field.
|
|
3
|
+
*
|
|
4
|
+
* Both apps had this, byte for byte: `normalizeHost`, `normalizeCode` and all
|
|
5
|
+
* of `parseServerInput`, down to the comments. The phone said so in its own
|
|
6
|
+
* header — a copy, because the desktop's `common` package was never published,
|
|
7
|
+
* written down in GRYT-406 rather than left to be found once the two had
|
|
8
|
+
* drifted.
|
|
9
|
+
*
|
|
10
|
+
* They had not drifted, which is the only comfortable moment to do this. The
|
|
11
|
+
* two apps talk to the same servers, so an address one reads differently is a
|
|
12
|
+
* server the other cannot join, and that failure looks like the server being
|
|
13
|
+
* down rather than like a parser disagreeing.
|
|
14
|
+
*
|
|
15
|
+
* What stayed behind is the scheme memory — `schemeFor`, `rememberScheme` and
|
|
16
|
+
* the rest. Those reach for storage and the two apps store differently, so they
|
|
17
|
+
* fail the test at the top of this package.
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeHost(input) {
|
|
20
|
+
let h = String(input || "").trim();
|
|
21
|
+
h = h.replace(/^(wss?:\/\/|https?:\/\/)/i, "");
|
|
22
|
+
h = h.split("/")[0] || "";
|
|
23
|
+
h = h.replace(/\s+/g, "");
|
|
24
|
+
return h;
|
|
25
|
+
}
|
|
26
|
+
export function normalizeCode(input) {
|
|
27
|
+
return String(input || "")
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/\s+/g, "")
|
|
30
|
+
.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The default host for a legacy `/invite/<code>` link.
|
|
34
|
+
*
|
|
35
|
+
* Those links carry no host, and the only client ever served from a path like
|
|
36
|
+
* that is the hosted one.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_LEGACY_HOST = "app.gryt.chat";
|
|
39
|
+
/**
|
|
40
|
+
* Three shapes arrive and they are not the same thing: a full invite link
|
|
41
|
+
* (`https://gryt.chat/invite?host=…&code=…`), a legacy one
|
|
42
|
+
* (`https://app.gryt.chat/invite/<code>`), and a plain address (`gryt.chat`,
|
|
43
|
+
* `192.168.1.5:5001`).
|
|
44
|
+
*
|
|
45
|
+
* `normalizeHost` on its own is wrong for the first two — it returns the
|
|
46
|
+
* *link's* host, which is gryt.chat, and joining that instead of the server
|
|
47
|
+
* named in the query is a confusing failure rather than an obvious one.
|
|
48
|
+
*
|
|
49
|
+
* Anything that does not parse as a link falls through to being an address, so
|
|
50
|
+
* a typo in a URL still gets the address treatment rather than an error about
|
|
51
|
+
* invite formats.
|
|
52
|
+
*/
|
|
53
|
+
export function parseServerInput(input, opts) {
|
|
54
|
+
const raw = String(input || "").trim();
|
|
55
|
+
if (!raw)
|
|
56
|
+
return { host: "", code: "" };
|
|
57
|
+
const legacyHost = normalizeHost(opts?.defaultLegacyHost || DEFAULT_LEGACY_HOST);
|
|
58
|
+
// Only something carrying a scheme can be a link. Without this, `gryt.chat`
|
|
59
|
+
// parses as a URL in some engines with "gryt.chat" as the protocol.
|
|
60
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(raw);
|
|
63
|
+
const path = url.pathname || "/";
|
|
64
|
+
// gryt://invite?host=…&code=… puts "invite" in the authority rather than
|
|
65
|
+
// the path, because the scheme is not one the URL parser treats as
|
|
66
|
+
// special. Both spellings mean the same thing.
|
|
67
|
+
const isInvite = path.startsWith("/invite") || url.hostname === "invite";
|
|
68
|
+
if (isInvite) {
|
|
69
|
+
const host = normalizeHost(url.searchParams.get("host") || "");
|
|
70
|
+
const code = normalizeCode(url.searchParams.get("code") || "");
|
|
71
|
+
if (host && code)
|
|
72
|
+
return { host, code };
|
|
73
|
+
const parts = path.split("/").filter(Boolean);
|
|
74
|
+
if (parts[0] === "invite" && parts[1]) {
|
|
75
|
+
return { host: legacyHost, code: normalizeCode(parts[1]) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not a URL after all. It is still probably an address.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { host: normalizeHost(raw), code: "" };
|
|
84
|
+
}
|
package/package.json
CHANGED