@gryt/core 0.24.0 → 0.26.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.
@@ -27,6 +27,8 @@ export interface ImportDeps {
27
27
  store: ImportFileStore;
28
28
  sleep: (ms: number) => Promise<void>;
29
29
  newId: () => string;
30
+ /** Downloads and uploads running together. A phone holding each file in memory wants fewer. */
31
+ transfersAtOnce?: number;
30
32
  }
31
33
  export interface ImportProgress {
32
34
  messages: {
@@ -1,5 +1,5 @@
1
1
  import { importBatches } from "../mls/content.js";
2
- const DOWNLOADS_AT_ONCE = 4;
2
+ const TRANSFERS_AT_ONCE = 16;
3
3
  /** `mls:send` allows about one send every two seconds sustained (RL_SEND in the server). */
4
4
  const SEND_GAP_MS = 2_000;
5
5
  const FILE_BATCH = 20;
@@ -51,7 +51,6 @@ export async function runDiscordImport(messages, deps, control, report) {
51
51
  };
52
52
  await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
53
53
  const missing = new Set();
54
- let next = 0;
55
54
  let clearAt = 0;
56
55
  const downloadFile = async (url) => {
57
56
  for (let attempt = 0; attempt <= DOWNLOAD_RETRIES && !control.stopped(); attempt++) {
@@ -66,47 +65,51 @@ export async function runDiscordImport(messages, deps, control, report) {
66
65
  }
67
66
  return null;
68
67
  };
69
- const worker = async () => {
70
- while (!control.stopped()) {
71
- await hold();
72
- if (next >= urls.length)
73
- break;
74
- const url = urls[next++];
75
- if (!(await deps.store.has(url))) {
76
- const file = await downloadFile(url);
77
- if (file) {
78
- p.bytes += file.bytes.length;
79
- await deps.store.put(url, file);
80
- }
81
- else {
82
- missing.add(url);
83
- p.missing = missing.size;
84
- }
68
+ const eachAtOnce = async (run) => {
69
+ let next = 0;
70
+ await Promise.all(Array.from({ length: deps.transfersAtOnce ?? TRANSFERS_AT_ONCE }, async () => {
71
+ while (!control.stopped()) {
72
+ await hold();
73
+ if (next >= urls.length)
74
+ break;
75
+ await run(urls[next++]);
85
76
  }
86
- p.downloaded.done++;
87
- tick();
88
- }
77
+ }));
89
78
  };
90
- await Promise.all(Array.from({ length: DOWNLOADS_AT_ONCE }, worker));
79
+ await eachAtOnce(async (url) => {
80
+ if (!(await deps.store.has(url))) {
81
+ const file = await downloadFile(url);
82
+ if (file) {
83
+ p.bytes += file.bytes.length;
84
+ await deps.store.put(url, file);
85
+ }
86
+ else {
87
+ missing.add(url);
88
+ p.missing = missing.size;
89
+ }
90
+ }
91
+ p.downloaded.done++;
92
+ tick();
93
+ });
91
94
  const uploaded = new Map();
95
+ await eachAtOnce(async (url) => {
96
+ const stored = missing.has(url) ? null : await deps.store.take(url);
97
+ if (stored) {
98
+ uploaded.set(url, await deps.upload({ name: nameOf(url), ...stored }));
99
+ await deps.store.drop(url);
100
+ }
101
+ p.sent.done++;
102
+ tick();
103
+ });
92
104
  let pending = [];
93
105
  for (const m of withFiles) {
94
106
  if (control.stopped())
95
107
  break;
96
108
  const attachments = {};
97
109
  for (const url of m.attachments) {
98
- await hold();
99
- let up = uploaded.get(url);
100
- const stored = up || missing.has(url) ? null : await deps.store.take(url);
101
- if (stored) {
102
- up = await deps.upload({ name: nameOf(url), ...stored });
103
- uploaded.set(url, up);
104
- await deps.store.drop(url);
105
- }
110
+ const up = uploaded.get(url);
106
111
  if (up)
107
112
  attachments[up.id] = up.key;
108
- p.sent.done++;
109
- tick();
110
113
  }
111
114
  const lost = m.attachments.length - Object.keys(attachments).length;
112
115
  const note = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
13
13
  export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
14
14
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
15
  export { reencryptedId, reencryptMine, type ReencryptCandidate, type ReencryptDeps } from "./mls/reencrypt.js";
16
- export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
16
+ export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
17
17
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
18
18
  export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
19
19
  export { inflateRaw, openZipBytes } from "./import/unzip.js";
@@ -9,6 +9,13 @@ export interface MlsHistoryEntry {
9
9
  text: string;
10
10
  replyTo?: string;
11
11
  attachments?: Record<string, SealedAttachmentKey>;
12
+ /** Who reacted, as the sender's record has it. A claim, like the rest of a shared copy. */
13
+ reactions?: MlsHistoryReaction[];
14
+ }
15
+ export interface MlsHistoryReaction {
16
+ /** A Unicode emoji or `:name:`, what `chat:react` carries. */
17
+ emoji: string;
18
+ users: string[];
12
19
  }
13
20
  /** One message from the sender's own Discord data. Who wrote it is never in here: the reader takes the MLS sender. */
14
21
  export interface MlsImportEntry {
@@ -17,6 +17,8 @@ const isText = (v) => typeof v === "string" && v.length <= MAX_TEXT;
17
17
  const isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
18
18
  const isLabel = (v) => typeof v === "string" && v.length <= MAX_LABEL;
19
19
  const MAX_HISTORY_ENTRIES = 200;
20
+ const MAX_HISTORY_REACTIONS = 20;
21
+ const MAX_REACTION_USERS = 50;
20
22
  /** Under what one `mls:send` takes once sealed and padded. */
21
23
  export const MAX_HISTORY_BATCH_BYTES = 48 * 1024;
22
24
  /** One emoji's worth of emoji characters, so a peer can't put a sentence on a message as a reaction. */
@@ -115,6 +117,8 @@ function historyBody(e) {
115
117
  out.replyTo = e.replyTo;
116
118
  if (e.attachments && Object.keys(e.attachments).length > 0)
117
119
  out.attachments = e.attachments;
120
+ if (e.reactions?.length)
121
+ out.reactions = e.reactions;
118
122
  return out;
119
123
  }
120
124
  function importBody(e) {
@@ -176,6 +180,25 @@ function readHistoryEntry(v) {
176
180
  return null;
177
181
  out.attachments = attachments;
178
182
  }
183
+ if (v.reactions !== undefined) {
184
+ const reactions = readHistoryReactions(v.reactions);
185
+ if (!reactions)
186
+ return null;
187
+ out.reactions = reactions;
188
+ }
189
+ return out;
190
+ }
191
+ function readHistoryReactions(v) {
192
+ if (!Array.isArray(v) || v.length === 0 || v.length > MAX_HISTORY_REACTIONS)
193
+ return null;
194
+ const out = [];
195
+ for (const r of v) {
196
+ if (!isObject(r) || !isReactionEmoji(r.emoji) || !Array.isArray(r.users))
197
+ return null;
198
+ if (r.users.length === 0 || r.users.length > MAX_REACTION_USERS || !r.users.every(isId))
199
+ return null;
200
+ out.push({ emoji: r.emoji, users: r.users });
201
+ }
179
202
  return out;
180
203
  }
181
204
  function readImportEntry(v) {
@@ -11,7 +11,10 @@ export interface ReencryptCandidate {
11
11
  sealed?: string | null;
12
12
  mls_placeholder?: object | null;
13
13
  attachments?: readonly string[] | null;
14
- reactions?: readonly object[] | null;
14
+ reactions?: readonly {
15
+ src: string;
16
+ users?: readonly string[];
17
+ }[] | null;
15
18
  poll?: object | null;
16
19
  pinned_at?: string | Date | null;
17
20
  thread_id?: string | null;
@@ -3,10 +3,11 @@ import { historyBatches } from "./content.js";
3
3
  const DELETE_GAP_MS = 3_100;
4
4
  const FETCH_GAP_MS = 700;
5
5
  export const reencryptedId = (messageId) => `re-${messageId}`;
6
- /** Plain text only: files, reactions, polls and pins can't travel in a history entry, so they stay. */
6
+ /* Files stay: an MLS file is deleted after the server's retention, a plaintext one isn't.
7
+ Polls and pins live on the server's row. */
7
8
  function canMove(m, me) {
8
9
  return m.sender_server_id === me && !!m.text && !m.mls && !m.sealed && !m.mls_placeholder
9
- && !m.attachments?.length && !m.reactions?.length && !m.poll && !m.pinned_at && !m.thread_id;
10
+ && !m.attachments?.length && !m.poll && !m.pinned_at && !m.thread_id;
10
11
  }
11
12
  async function plaintextOfMine(deps, me) {
12
13
  const out = [];
@@ -30,12 +31,14 @@ export async function reencryptMine(deps, me) {
30
31
  const originals = new Map(mine.map((m) => [reencryptedId(m.message_id), m.message_id]));
31
32
  const entries = mine.map((m) => {
32
33
  const reply = m.reply_to_message_id;
34
+ const reactions = (m.reactions ?? []).flatMap((r) => (r.users?.length ? [{ emoji: r.src, users: [...r.users] }] : []));
33
35
  return {
34
36
  id: reencryptedId(m.message_id),
35
37
  senderId: me,
36
38
  sentAt: new Date(m.created_at).getTime(),
37
39
  text: m.text ?? "",
38
40
  ...(reply ? { replyTo: ids.has(reply) ? reencryptedId(reply) : reply } : {}),
41
+ ...(reactions.length ? { reactions } : {}),
39
42
  };
40
43
  });
41
44
  const batches = historyBatches(entries, deps.newId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.24.0",
3
+ "version": "0.26.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": {