@gryt/core 0.11.0 → 0.12.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 +4 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/mls/content.d.ts +13 -0
- package/dist/mls/content.js +47 -2
- package/dist/mls/reactions.d.ts +13 -0
- package/dist/mls/reactions.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,7 +53,10 @@ goes missing **and** when one appears that nobody listed.
|
|
|
53
53
|
the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the
|
|
54
54
|
pins from the peer pins `@gryt/crypto` already keeps.
|
|
55
55
|
`encodeMlsDmContent` and `decodeMlsDmContent` write and read what goes inside
|
|
56
|
-
each message, so both apps use the same JSON
|
|
56
|
+
each message, so both apps use the same JSON: a message, an edit, a delete or
|
|
57
|
+
a reaction. `readMlsDmContent` also says when something came from a newer app,
|
|
58
|
+
so it can be skipped quietly rather than counted as broken. `applyMlsReaction` puts a reaction on a message in the archive,
|
|
59
|
+
since the server never sees one. It's the one module with a
|
|
57
60
|
dependency, `@gryt/crypto`, pinned exactly the way the apps pin it.
|
|
58
61
|
|
|
59
62
|
## What deliberately isn't
|
package/dist/index.d.ts
CHANGED
|
@@ -12,5 +12,6 @@ export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissi
|
|
|
12
12
|
export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
|
|
13
13
|
export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
|
|
14
14
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
15
|
-
export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, type MlsDmContent, } from "./mls/content.js";
|
|
15
|
+
export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, type MlsDmContent, readMlsDmContent, } from "./mls/content.js";
|
|
16
|
+
export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
|
|
16
17
|
export type { DmSealingMode, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsLogEntry, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
|
package/dist/index.js
CHANGED
|
@@ -14,4 +14,5 @@ export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissi
|
|
|
14
14
|
export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
|
|
15
15
|
export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
|
|
16
16
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
17
|
-
export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, } from "./mls/content.js";
|
|
17
|
+
export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
|
|
18
|
+
export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
|
package/dist/mls/content.d.ts
CHANGED
|
@@ -14,9 +14,22 @@ export type MlsDmContent = {
|
|
|
14
14
|
} | {
|
|
15
15
|
type: "delete";
|
|
16
16
|
id: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "reaction";
|
|
19
|
+
/** The message reacted to, the way edits and deletes name theirs. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** A Unicode emoji, or a server emoji as `:name:`, the same string `chat:react` carries. */
|
|
22
|
+
emoji: string;
|
|
23
|
+
/** Explicit rather than a toggle, so a repeat or a reorder lands the same on every device. */
|
|
24
|
+
action: "add" | "remove";
|
|
17
25
|
};
|
|
18
26
|
export declare const MLS_DM_CONTENT_VERSION = 1;
|
|
19
27
|
/** The same bytes the phone writes: `v` first, then the content's fields in this order. */
|
|
20
28
|
export declare function encodeMlsDmContent(content: MlsDmContent): Uint8Array;
|
|
21
29
|
/** Null for anything this version can't read: a newer version, a new type, or junk. */
|
|
22
30
|
export declare function decodeMlsDmContent(bytes: Uint8Array): MlsDmContent | null;
|
|
31
|
+
/**
|
|
32
|
+
* Like `decodeMlsDmContent`, but tells content from a newer app ("newer", skip it quietly)
|
|
33
|
+
* apart from junk (null, which is worth counting). A known type with a bad field is junk.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readMlsDmContent(bytes: Uint8Array): MlsDmContent | "newer" | null;
|
package/dist/mls/content.js
CHANGED
|
@@ -6,10 +6,27 @@ const MAX_TEXT = 32_000;
|
|
|
6
6
|
const MAX_ATTACHMENTS = 10;
|
|
7
7
|
const MAX_LABEL = 255;
|
|
8
8
|
const UPLOAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
9
|
+
/** The server's own rule for an emoji's name, in `emojiShared.ts`. */
|
|
10
|
+
const CUSTOM_EMOJI = /^:[A-Za-z0-9_]{2,32}:$/;
|
|
11
|
+
/** The longest RGI emoji, a kiss with two skin tones, is ten code points. */
|
|
12
|
+
const MAX_EMOJI_CODE_POINTS = 12;
|
|
13
|
+
const EMOJI_PART = /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|\uFE0E)$/u;
|
|
14
|
+
const EMOJI_BASE = /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20E3)$/u;
|
|
9
15
|
const isId = (v) => typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
|
|
10
16
|
const isText = (v) => typeof v === "string" && v.length <= MAX_TEXT;
|
|
11
17
|
const isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
12
18
|
const isLabel = (v) => typeof v === "string" && v.length <= MAX_LABEL;
|
|
19
|
+
/** One emoji's worth of emoji characters, so a peer can't put a sentence on a message as a reaction. */
|
|
20
|
+
function isReactionEmoji(v) {
|
|
21
|
+
if (typeof v !== "string")
|
|
22
|
+
return false;
|
|
23
|
+
if (CUSTOM_EMOJI.test(v))
|
|
24
|
+
return true;
|
|
25
|
+
const points = Array.from(v);
|
|
26
|
+
if (points.length === 0 || points.length > MAX_EMOJI_CODE_POINTS)
|
|
27
|
+
return false;
|
|
28
|
+
return points.every((p) => EMOJI_PART.test(p)) && points.some((p) => EMOJI_BASE.test(p));
|
|
29
|
+
}
|
|
13
30
|
function isObject(v) {
|
|
14
31
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
32
|
}
|
|
@@ -72,6 +89,9 @@ export function encodeMlsDmContent(content) {
|
|
|
72
89
|
else if (content.type === "edit") {
|
|
73
90
|
body = { type: content.type, id: content.id, text: content.text };
|
|
74
91
|
}
|
|
92
|
+
else if (content.type === "reaction") {
|
|
93
|
+
body = { type: content.type, id: content.id, emoji: content.emoji, action: content.action };
|
|
94
|
+
}
|
|
75
95
|
else {
|
|
76
96
|
body = { type: content.type, id: content.id };
|
|
77
97
|
}
|
|
@@ -82,13 +102,35 @@ export function encodeMlsDmContent(content) {
|
|
|
82
102
|
}
|
|
83
103
|
/** Null for anything this version can't read: a newer version, a new type, or junk. */
|
|
84
104
|
export function decodeMlsDmContent(bytes) {
|
|
85
|
-
|
|
105
|
+
return fromRaw(parse(bytes));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Like `decodeMlsDmContent`, but tells content from a newer app ("newer", skip it quietly)
|
|
109
|
+
* apart from junk (null, which is worth counting). A known type with a bad field is junk.
|
|
110
|
+
*/
|
|
111
|
+
export function readMlsDmContent(bytes) {
|
|
112
|
+
const raw = parse(bytes);
|
|
113
|
+
return fromRaw(raw) ?? (isNewer(raw) ? "newer" : null);
|
|
114
|
+
}
|
|
115
|
+
const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction"]);
|
|
116
|
+
const TYPE_NAME = /^[a-z][a-z0-9_]{0,31}$/;
|
|
117
|
+
/** A later version, or a type this one doesn't know that names its message the way ours do. */
|
|
118
|
+
function isNewer(raw) {
|
|
119
|
+
if (!isObject(raw) || typeof raw.type !== "string" || !TYPE_NAME.test(raw.type))
|
|
120
|
+
return false;
|
|
121
|
+
if (raw.v === MLS_DM_CONTENT_VERSION)
|
|
122
|
+
return !KNOWN_TYPES.has(raw.type) && isId(raw.id);
|
|
123
|
+
return typeof raw.v === "number" && Number.isSafeInteger(raw.v) && raw.v > MLS_DM_CONTENT_VERSION;
|
|
124
|
+
}
|
|
125
|
+
function parse(bytes) {
|
|
86
126
|
try {
|
|
87
|
-
|
|
127
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
88
128
|
}
|
|
89
129
|
catch {
|
|
90
130
|
return null;
|
|
91
131
|
}
|
|
132
|
+
}
|
|
133
|
+
function fromRaw(raw) {
|
|
92
134
|
if (!isObject(raw) || raw.v !== MLS_DM_CONTENT_VERSION || !isId(raw.id))
|
|
93
135
|
return null;
|
|
94
136
|
const { type, id, text } = raw;
|
|
@@ -111,5 +153,8 @@ export function decodeMlsDmContent(bytes) {
|
|
|
111
153
|
return { type, id, text };
|
|
112
154
|
if (type === "delete")
|
|
113
155
|
return { type, id };
|
|
156
|
+
if (type === "reaction" && isReactionEmoji(raw.emoji) && (raw.action === "add" || raw.action === "remove")) {
|
|
157
|
+
return { type, id, emoji: raw.emoji, action: raw.action };
|
|
158
|
+
}
|
|
114
159
|
return null;
|
|
115
160
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MlsReaction {
|
|
2
|
+
src: string;
|
|
3
|
+
amount: number;
|
|
4
|
+
users: string[];
|
|
5
|
+
}
|
|
6
|
+
/** Adding twice or removing what isn't there changes nothing, so replays and reorders are safe. */
|
|
7
|
+
export declare function applyMlsReaction(reactions: readonly MlsReaction[] | null | undefined, change: {
|
|
8
|
+
emoji: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
action: "add" | "remove";
|
|
11
|
+
}): MlsReaction[];
|
|
12
|
+
/** What a tap on a reaction chip means for this person: take theirs off, or put it on. */
|
|
13
|
+
export declare function mlsReactionAction(reactions: readonly MlsReaction[] | null | undefined, emoji: string, userId: string): "add" | "remove";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* Reactions on an MLS message live only in each device's archive. One reducer for both apps,
|
|
2
|
+
in the `{ src, amount, users }` shape the server sends for every other message. */
|
|
3
|
+
/** Adding twice or removing what isn't there changes nothing, so replays and reorders are safe. */
|
|
4
|
+
export function applyMlsReaction(reactions, change) {
|
|
5
|
+
const { emoji, userId, action } = change;
|
|
6
|
+
const list = (reactions ?? []).map((r) => ({ src: r.src, amount: r.users.length, users: [...r.users] }));
|
|
7
|
+
const existing = list.find((r) => r.src === emoji);
|
|
8
|
+
if (action === "add") {
|
|
9
|
+
if (!existing)
|
|
10
|
+
list.push({ src: emoji, amount: 1, users: [userId] });
|
|
11
|
+
else if (!existing.users.includes(userId)) {
|
|
12
|
+
existing.users.push(userId);
|
|
13
|
+
existing.amount = existing.users.length;
|
|
14
|
+
}
|
|
15
|
+
return list;
|
|
16
|
+
}
|
|
17
|
+
if (existing) {
|
|
18
|
+
existing.users = existing.users.filter((u) => u !== userId);
|
|
19
|
+
existing.amount = existing.users.length;
|
|
20
|
+
}
|
|
21
|
+
return list.filter((r) => r.amount > 0);
|
|
22
|
+
}
|
|
23
|
+
/** What a tap on a reaction chip means for this person: take theirs off, or put it on. */
|
|
24
|
+
export function mlsReactionAction(reactions, emoji, userId) {
|
|
25
|
+
return reactions?.some((r) => r.src === emoji && r.users.includes(userId)) ? "remove" : "add";
|
|
26
|
+
}
|
package/package.json
CHANGED