@gryt/core 0.10.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 +7 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mls/content.d.ts +35 -0
- package/dist/mls/content.js +160 -0
- package/dist/mls/dmDriver.d.ts +1 -1
- package/dist/mls/dmDriver.js +58 -20
- package/dist/mls/interfaces.d.ts +5 -1
- package/dist/mls/reactions.d.ts +13 -0
- package/dist/mls/reactions.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,8 +51,13 @@ goes missing **and** when one appears that nobody listed.
|
|
|
51
51
|
topped up, opens a DM group, joins from a Welcome, sends, and reads the group
|
|
52
52
|
log in order. Each app hands it the socket, the storage and the pins through
|
|
53
53
|
the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the
|
|
54
|
-
pins from the peer pins `@gryt/crypto` already keeps.
|
|
55
|
-
|
|
54
|
+
pins from the peer pins `@gryt/crypto` already keeps.
|
|
55
|
+
`encodeMlsDmContent` and `decodeMlsDmContent` write and read what goes inside
|
|
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
|
|
60
|
+
dependency, `@gryt/crypto`, pinned exactly the way the apps pin it.
|
|
56
61
|
|
|
57
62
|
## What deliberately isn't
|
|
58
63
|
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +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, readMlsDmContent, } from "./mls/content.js";
|
|
16
|
+
export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
|
|
15
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,3 +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, readMlsDmContent, } from "./mls/content.js";
|
|
18
|
+
export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type SealedAttachmentKey } from "@gryt/crypto";
|
|
2
|
+
export type MlsDmContent = {
|
|
3
|
+
type: "message";
|
|
4
|
+
/** Made by the sender, a UUID. Edits and deletes name it. */
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
replyTo?: string;
|
|
8
|
+
/** Upload id to what `sealAttachment` returned; `openAttachment` takes it as `meta`. */
|
|
9
|
+
attachments?: Record<string, SealedAttachmentKey>;
|
|
10
|
+
} | {
|
|
11
|
+
type: "edit";
|
|
12
|
+
id: string;
|
|
13
|
+
text: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: "delete";
|
|
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";
|
|
25
|
+
};
|
|
26
|
+
export declare const MLS_DM_CONTENT_VERSION = 1;
|
|
27
|
+
/** The same bytes the phone writes: `v` first, then the content's fields in this order. */
|
|
28
|
+
export declare function encodeMlsDmContent(content: MlsDmContent): Uint8Array;
|
|
29
|
+
/** Null for anything this version can't read: a newer version, a new type, or junk. */
|
|
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;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { base64UrlDecode } from "@gryt/crypto";
|
|
2
|
+
export const MLS_DM_CONTENT_VERSION = 1;
|
|
3
|
+
const MAX_ID = 64;
|
|
4
|
+
/** Well past the composer's limit, and under what one `mls:send` can carry. */
|
|
5
|
+
const MAX_TEXT = 32_000;
|
|
6
|
+
const MAX_ATTACHMENTS = 10;
|
|
7
|
+
const MAX_LABEL = 255;
|
|
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;
|
|
15
|
+
const isId = (v) => typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
|
|
16
|
+
const isText = (v) => typeof v === "string" && v.length <= MAX_TEXT;
|
|
17
|
+
const isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
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
|
+
}
|
|
30
|
+
function isObject(v) {
|
|
31
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32
|
+
}
|
|
33
|
+
function decodesTo(v, bytes) {
|
|
34
|
+
if (typeof v !== "string")
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
return base64UrlDecode(v).length === bytes;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Only the fields a reader uses, each checked; null for anything off. */
|
|
44
|
+
function readAttachmentKey(v) {
|
|
45
|
+
if (!isObject(v) || !isId(v.id) || !decodesTo(v.key, 32) || !decodesTo(v.iv, 12))
|
|
46
|
+
return null;
|
|
47
|
+
const out = { id: v.id, key: v.key, iv: v.iv };
|
|
48
|
+
for (const field of ["name", "mime"]) {
|
|
49
|
+
if (v[field] === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
if (!isLabel(v[field]))
|
|
52
|
+
return null;
|
|
53
|
+
out[field] = v[field];
|
|
54
|
+
}
|
|
55
|
+
for (const field of ["size", "width", "height"]) {
|
|
56
|
+
if (v[field] === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
if (!isCount(v[field]))
|
|
59
|
+
return null;
|
|
60
|
+
out[field] = v[field];
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function readAttachments(v) {
|
|
65
|
+
if (!isObject(v))
|
|
66
|
+
return null;
|
|
67
|
+
const entries = Object.entries(v);
|
|
68
|
+
if (entries.length === 0 || entries.length > MAX_ATTACHMENTS)
|
|
69
|
+
return null;
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [uploadId, meta] of entries) {
|
|
72
|
+
const key = readAttachmentKey(meta);
|
|
73
|
+
if (!UPLOAD_ID.test(uploadId) || !key)
|
|
74
|
+
return null;
|
|
75
|
+
out[uploadId] = key;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** The same bytes the phone writes: `v` first, then the content's fields in this order. */
|
|
80
|
+
export function encodeMlsDmContent(content) {
|
|
81
|
+
let body;
|
|
82
|
+
if (content.type === "message") {
|
|
83
|
+
body = { type: content.type, id: content.id, text: content.text };
|
|
84
|
+
if (content.replyTo !== undefined)
|
|
85
|
+
body.replyTo = content.replyTo;
|
|
86
|
+
if (content.attachments && Object.keys(content.attachments).length > 0)
|
|
87
|
+
body.attachments = content.attachments;
|
|
88
|
+
}
|
|
89
|
+
else if (content.type === "edit") {
|
|
90
|
+
body = { type: content.type, id: content.id, text: content.text };
|
|
91
|
+
}
|
|
92
|
+
else if (content.type === "reaction") {
|
|
93
|
+
body = { type: content.type, id: content.id, emoji: content.emoji, action: content.action };
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
body = { type: content.type, id: content.id };
|
|
97
|
+
}
|
|
98
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, ...body }));
|
|
99
|
+
if (!decodeMlsDmContent(bytes))
|
|
100
|
+
throw new Error("That content wouldn't read back, so it isn't sent.");
|
|
101
|
+
return bytes;
|
|
102
|
+
}
|
|
103
|
+
/** Null for anything this version can't read: a newer version, a new type, or junk. */
|
|
104
|
+
export function decodeMlsDmContent(bytes) {
|
|
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) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function fromRaw(raw) {
|
|
134
|
+
if (!isObject(raw) || raw.v !== MLS_DM_CONTENT_VERSION || !isId(raw.id))
|
|
135
|
+
return null;
|
|
136
|
+
const { type, id, text } = raw;
|
|
137
|
+
if (type === "message" && isText(text)) {
|
|
138
|
+
const out = { type, id, text };
|
|
139
|
+
if (raw.replyTo !== undefined) {
|
|
140
|
+
if (!isId(raw.replyTo))
|
|
141
|
+
return null;
|
|
142
|
+
out.replyTo = raw.replyTo;
|
|
143
|
+
}
|
|
144
|
+
if (raw.attachments !== undefined) {
|
|
145
|
+
const attachments = readAttachments(raw.attachments);
|
|
146
|
+
if (!attachments)
|
|
147
|
+
return null;
|
|
148
|
+
out.attachments = attachments;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
if (type === "edit" && isText(text))
|
|
153
|
+
return { type, id, text };
|
|
154
|
+
if (type === "delete")
|
|
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
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
package/dist/mls/dmDriver.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MlsDmDriver, MlsDmDriverOptions, MlsRefusal } from "./interfaces.js";
|
|
2
|
-
export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "commit_retries";
|
|
2
|
+
export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries";
|
|
3
3
|
export declare class MlsDriverError extends Error {
|
|
4
4
|
code: MlsDriverErrorCode;
|
|
5
5
|
/** The server's refusal, when there was one. */
|
package/dist/mls/dmDriver.js
CHANGED
|
@@ -2,7 +2,12 @@ import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decode
|
|
|
2
2
|
/** Five devices per person per server (design, section 1). The server holds the same line. */
|
|
3
3
|
const MLS_MAX_DEVICES_PER_PERSON = 5;
|
|
4
4
|
const COMMIT_ATTEMPTS = 5;
|
|
5
|
-
|
|
5
|
+
/** socket.io closes the connection on a packet with more than 10 binary parts (GRYT-1522). */
|
|
6
|
+
const MAX_BINARY_PARTS = 10;
|
|
7
|
+
const LOG_PAGE = MAX_BINARY_PARTS;
|
|
8
|
+
const PUBLISH_BATCH = MAX_BINARY_PARTS - 1;
|
|
9
|
+
/** The server's cap on files in one message. */
|
|
10
|
+
const MAX_ATTACHMENT_IDS = 10;
|
|
6
11
|
const WELCOME_WAIT_MS = 10_000;
|
|
7
12
|
/** The server keeps Welcomes 30 days; a last-resort package is replaced with a week to go. */
|
|
8
13
|
const WELCOME_KEEP_S = 30 * 86_400;
|
|
@@ -131,14 +136,20 @@ export function createMlsDmDriver(options) {
|
|
|
131
136
|
const last = needLastResort ? await make(true) : undefined;
|
|
132
137
|
// The private halves are stored first, so a Welcome can never name one this device lost.
|
|
133
138
|
await store.putKeyPackages(last ? [...fresh, last] : fresh);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
// Nine at a time, and the last-resort one rides with the last batch.
|
|
140
|
+
const all = fresh.map((k) => k.keyPackage);
|
|
141
|
+
for (let i = 0; i === 0 || i < all.length; i += PUBLISH_BATCH) {
|
|
142
|
+
const keyPackages = all.slice(i, i + PUBLISH_BATCH);
|
|
143
|
+
const final = i + PUBLISH_BATCH >= all.length;
|
|
144
|
+
const r = await transport.publishKeyPackages({
|
|
145
|
+
deviceId: d.deviceId,
|
|
146
|
+
keyPackages,
|
|
147
|
+
lastResort: final ? last?.keyPackage : undefined,
|
|
148
|
+
});
|
|
149
|
+
if (!r.ok)
|
|
150
|
+
throw refused("Publishing KeyPackages", r);
|
|
151
|
+
registered = true;
|
|
152
|
+
}
|
|
142
153
|
}
|
|
143
154
|
async function syncAndTopUp(forceLastResort = false) {
|
|
144
155
|
const d = await ownDevice();
|
|
@@ -411,8 +422,7 @@ export function createMlsDmDriver(options) {
|
|
|
411
422
|
async function ensureOpen(conversationId) {
|
|
412
423
|
if ((await run(conversationId, () => openGroup(conversationId))) === "open")
|
|
413
424
|
return;
|
|
414
|
-
|
|
415
|
-
await handleWelcome(w);
|
|
425
|
+
await takeWaitingWelcomes();
|
|
416
426
|
if (!(await store.loadGroup(conversationId)))
|
|
417
427
|
await waitForWelcome(conversationId);
|
|
418
428
|
}
|
|
@@ -457,27 +467,56 @@ export function createMlsDmDriver(options) {
|
|
|
457
467
|
await catchUp(open);
|
|
458
468
|
return { result: "joined", usedLastResort: kp.lastResort };
|
|
459
469
|
}
|
|
460
|
-
|
|
470
|
+
/** "retry" when it's kept for later, else whether it used a last-resort package. */
|
|
471
|
+
async function takeWelcome(w) {
|
|
461
472
|
const d = await ownDevice();
|
|
462
473
|
if (w.deviceId !== d.deviceId || !w.conversationId)
|
|
463
|
-
return;
|
|
474
|
+
return "retry";
|
|
464
475
|
const conversationId = w.conversationId;
|
|
465
476
|
const { result, usedLastResort } = await run(conversationId, () => joinFrom(w));
|
|
466
477
|
if (result === "retry")
|
|
467
|
-
return;
|
|
478
|
+
return "retry";
|
|
468
479
|
await transport.ackWelcomes({ deviceId: d.deviceId, welcomeIds: [w.welcomeId] });
|
|
469
480
|
if (result !== "joined")
|
|
470
|
-
return;
|
|
481
|
+
return "done";
|
|
471
482
|
reconciled.delete(conversationId);
|
|
472
483
|
events.onJoined?.({ conversationId, groupId: w.groupId });
|
|
473
484
|
for (const done of welcomeWaiters.get(conversationId) ?? [])
|
|
474
485
|
done();
|
|
475
486
|
welcomeWaiters.delete(conversationId);
|
|
487
|
+
return usedLastResort ? "used_last_resort" : "done";
|
|
488
|
+
}
|
|
489
|
+
async function handleWelcome(w) {
|
|
476
490
|
// One package was used up, and a used last-resort one gets replaced.
|
|
477
|
-
await
|
|
491
|
+
const outcome = await takeWelcome(w);
|
|
492
|
+
if (outcome !== "retry")
|
|
493
|
+
await syncAndTopUp(outcome === "used_last_resort");
|
|
494
|
+
}
|
|
495
|
+
/* Sync until nothing waiting can be acted on: a server may hand out ten Welcomes a time,
|
|
496
|
+
and each one taken is acked, so the next sync brings the rest (GRYT-1528). */
|
|
497
|
+
async function takeWaitingWelcomes() {
|
|
498
|
+
let lastResortUsed = false;
|
|
499
|
+
for (let round = 0; round < 20; round++) {
|
|
500
|
+
const welcomes = await syncAndTopUp(lastResortUsed);
|
|
501
|
+
lastResortUsed = false;
|
|
502
|
+
let taken = 0;
|
|
503
|
+
for (const w of welcomes) {
|
|
504
|
+
const outcome = await takeWelcome(w);
|
|
505
|
+
if (outcome !== "retry")
|
|
506
|
+
taken += 1;
|
|
507
|
+
if (outcome === "used_last_resort")
|
|
508
|
+
lastResortUsed = true;
|
|
509
|
+
}
|
|
510
|
+
if (taken === 0)
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
478
513
|
}
|
|
479
514
|
// ── What the app calls ──────────────────────────────────────────────
|
|
480
|
-
async function send(conversationId, peer, plaintext) {
|
|
515
|
+
async function send(conversationId, peer, plaintext, options = {}) {
|
|
516
|
+
const attachmentIds = options.attachmentIds?.length ? [...options.attachmentIds] : undefined;
|
|
517
|
+
if (attachmentIds && attachmentIds.length > MAX_ATTACHMENT_IDS) {
|
|
518
|
+
throw new MlsDriverError("too_many_attachments", `At most ${MAX_ATTACHMENT_IDS} files go with one message.`);
|
|
519
|
+
}
|
|
481
520
|
if (!registered)
|
|
482
521
|
throw new MlsDriverError("no_device", "This device isn't registered for MLS here. Call start() first.");
|
|
483
522
|
await ensureOpen(conversationId);
|
|
@@ -500,7 +539,7 @@ export function createMlsDmDriver(options) {
|
|
|
500
539
|
open.state = out.state;
|
|
501
540
|
// The generation is used up whether or not the send arrives, so the state is saved first.
|
|
502
541
|
await save(open);
|
|
503
|
-
const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message });
|
|
542
|
+
const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message, attachmentIds });
|
|
504
543
|
if (!r.ok)
|
|
505
544
|
throw refused("Sending", r);
|
|
506
545
|
return { seq: r.seq };
|
|
@@ -559,8 +598,7 @@ export function createMlsDmDriver(options) {
|
|
|
559
598
|
await ownDevice();
|
|
560
599
|
if (!capability)
|
|
561
600
|
return;
|
|
562
|
-
|
|
563
|
-
await handleWelcome(w);
|
|
601
|
+
await takeWaitingWelcomes();
|
|
564
602
|
reconciled.clear();
|
|
565
603
|
for (const rec of await store.listGroups()) {
|
|
566
604
|
await run(rec.conversationId, async () => {
|
package/dist/mls/interfaces.d.ts
CHANGED
|
@@ -93,10 +93,12 @@ export interface MlsTransport {
|
|
|
93
93
|
seq: number;
|
|
94
94
|
epoch: number;
|
|
95
95
|
}>>;
|
|
96
|
+
/** `attachmentIds` are the uploads the message carries, so the server can tie them to it. */
|
|
96
97
|
send(req: {
|
|
97
98
|
conversationId: string;
|
|
98
99
|
deviceId: string;
|
|
99
100
|
message: Uint8Array;
|
|
101
|
+
attachmentIds?: string[];
|
|
100
102
|
}): Promise<MlsReply<{
|
|
101
103
|
seq: number;
|
|
102
104
|
}>>;
|
|
@@ -260,7 +262,9 @@ export interface MlsDmDriver {
|
|
|
260
262
|
start(): Promise<void>;
|
|
261
263
|
modeFor(conversationId: string, peerServerUserId: string): Promise<DmSealingMode>;
|
|
262
264
|
/** Opens the group if need be, adds or removes devices that changed, then sends. */
|
|
263
|
-
send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array
|
|
265
|
+
send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array, options?: {
|
|
266
|
+
attachmentIds?: readonly string[];
|
|
267
|
+
}): Promise<{
|
|
264
268
|
seq: number;
|
|
265
269
|
}>;
|
|
266
270
|
handleMessage(entry: MlsLogEntry): Promise<void>;
|
|
@@ -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