@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 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. It's the one module with a
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";
@@ -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;
@@ -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
- let raw;
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
- raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": {