@majikah/majik-message 0.2.20 → 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.
@@ -0,0 +1,240 @@
1
+ import { MajikContact, MajikContactData, MajikContactGroup, MajikContactGroupMeta } from "@majikah/majik-contact";
2
+ import { MajikContactDirectory } from "./majik-contact-directory";
3
+ import { MajikContactGroupManager } from "./majik-contact-groups";
4
+ import { MAJIK_API_RESPONSE } from "../types";
5
+ import { MessageEnvelope } from "../messages/message-envelope";
6
+ import { MajikContactManagerJSON } from "./types";
7
+ /**
8
+ * Unified facade over MajikContactDirectory and MajikContactGroupManager.
9
+ *
10
+ * Responsibilities:
11
+ * - Owns both the directory and the group manager as a single cohesive unit
12
+ * - Proxies all directory methods so MajikMessage call sites need only change
13
+ * `contactDirectory` → `contacts` with no logic changes
14
+ * - Wires lifecycle hooks automatically so callers can never forget them:
15
+ * • removeContact() → always calls groups.handleContactRemoved()
16
+ * • blockContact() → always syncs the Blocked system group
17
+ * • unblockContact() → always syncs the Blocked system group
18
+ * - Exposes the group manager via `.group` for all group-specific operations
19
+ * - Serializes both directory and groups into one unified payload for
20
+ * MajikMessage.toJSON() / MajikMessage.fromJSON()
21
+ *
22
+ * Construction:
23
+ * - Pass nothing → fresh directory + fresh group manager (new session)
24
+ * - Pass a directory → wraps it, creates a fresh group manager bound to it
25
+ * - Pass both → fully restores a prior session (used by fromJSON)
26
+ */
27
+ export declare class MajikContactManager {
28
+ private readonly directory;
29
+ private readonly groupManager;
30
+ constructor(directory?: MajikContactDirectory, groupManager?: MajikContactGroupManager);
31
+ /**
32
+ * Direct access to the full MajikContactGroupManager API.
33
+ * Use for all group-specific operations:
34
+ * manager.group.addToFavorites(contactId)
35
+ * manager.group.createGroup(id, name)
36
+ * manager.group.getContactsInGroup(groupId)
37
+ * etc.
38
+ */
39
+ get group(): MajikContactGroupManager;
40
+ /**
41
+ * Direct access to the underlying MajikContactDirectory.
42
+ * Prefer the proxied methods on this class over accessing the directory
43
+ * directly — they keep group state in sync automatically.
44
+ */
45
+ get directory_(): MajikContactDirectory;
46
+ addContact(contact: MajikContact): this;
47
+ addContacts(contacts: MajikContact[]): this;
48
+ /**
49
+ * Removes a contact from the directory and automatically removes them
50
+ * from every group they belong to via the group manager hook.
51
+ * The two operations are always atomic from the caller's perspective.
52
+ */
53
+ removeContact(id: string): MAJIK_API_RESPONSE;
54
+ getContact(id: string): MajikContact | undefined;
55
+ getContactByFingerprint(fingerprint: string): MajikContact | undefined;
56
+ getContactByPublicKeyBase64(publicKeyBase64: string): Promise<MajikContact | undefined>;
57
+ hasContact(id: string): boolean;
58
+ hasFingerprint(fingerprint: string): boolean;
59
+ hasContactByPublicKeyBase64(publicKeyBase64: string): Promise<boolean>;
60
+ listContacts(sortedByLabel?: boolean, majikahOnly?: boolean): MajikContact[];
61
+ updateContactMeta(id: string, meta: Partial<MajikContactData["meta"]>): MajikContact;
62
+ /**
63
+ * Blocks a contact on the directory AND adds them to the system Blocked
64
+ * group — both sides are always kept in sync.
65
+ */
66
+ blockContact(id: string): MajikContact;
67
+ /**
68
+ * Unblocks a contact on the directory AND removes them from the system
69
+ * Blocked group — both sides are always kept in sync.
70
+ */
71
+ unblockContact(id: string): MajikContact;
72
+ setMajikahStatus(id: string, status: boolean): MajikContact;
73
+ isMajikahRegistered(id: string): boolean;
74
+ isMajikahIdentityChecked(id: string): boolean;
75
+ hasContactForEnvelope(envelope: MessageEnvelope): boolean;
76
+ /**
77
+ * Creates and registers a new user-defined group.
78
+ * Throws if a group with the same ID already exists.
79
+ */
80
+ createGroup(id: string, name: string, meta?: Partial<Omit<MajikContactGroupMeta, "name">>, initialMemberIds?: string[]): MajikContactGroup;
81
+ /**
82
+ * Registers an already-constructed MajikContactGroup instance.
83
+ * Throws if a group with the same ID already exists.
84
+ */
85
+ addGroup(group: MajikContactGroup): this;
86
+ /**
87
+ * Removes a user group by ID.
88
+ * System groups (Favorites, Blocked) cannot be deleted.
89
+ */
90
+ removeGroup(id: string): MAJIK_API_RESPONSE;
91
+ /**
92
+ * Returns a group by ID, or undefined if not found.
93
+ */
94
+ getGroup(id: string): MajikContactGroup | undefined;
95
+ /**
96
+ * Returns a group by ID. Throws if not found.
97
+ */
98
+ getGroupOrThrow(id: string): MajikContactGroup;
99
+ /**
100
+ * Returns true if a group with the given ID exists.
101
+ */
102
+ hasGroup(id: string): boolean;
103
+ /**
104
+ * Returns all groups.
105
+ *
106
+ * @param includeSystem Include system groups (Favorites, Blocked). Default: true.
107
+ * @param sortedByName Sort results alphabetically by group name. Default: false.
108
+ */
109
+ listGroups(includeSystem?: boolean, sortedByName?: boolean): MajikContactGroup[];
110
+ /**
111
+ * Returns only user-created groups (excludes Favorites and Blocked).
112
+ * Sorted alphabetically by name.
113
+ */
114
+ listUserGroups(sortedByName?: boolean): MajikContactGroup[];
115
+ /**
116
+ * Returns only system groups (Favorites and Blocked).
117
+ */
118
+ listSystemGroups(): MajikContactGroup[];
119
+ /**
120
+ * Updates mutable metadata on a group (name, description).
121
+ * Name is locked on system groups — will throw if attempted.
122
+ */
123
+ updateGroupMeta(id: string, meta: Partial<Pick<MajikContactGroupMeta, "name" | "description">>): MajikContactGroup;
124
+ /**
125
+ * Adds a contact to a group.
126
+ * Validates the contact exists in the directory.
127
+ * If the group is the system Blocked group, also calls contact.block().
128
+ * Throws if the contact is already a member — use addContactToGroupIfAbsent for idempotent.
129
+ */
130
+ addContactToGroup(groupId: string, contactId: string): MajikContactGroup;
131
+ /**
132
+ * Idempotent variant — does not throw if the contact is already a member.
133
+ */
134
+ addContactToGroupIfAbsent(groupId: string, contactId: string): MajikContactGroup;
135
+ /**
136
+ * Adds multiple contacts to a group in one call (all-or-nothing).
137
+ */
138
+ addContactsToGroup(groupId: string, contactIds: string[]): MajikContactGroup;
139
+ /**
140
+ * Removes a contact from a group.
141
+ * If the group is the system Blocked group, also calls contact.unblock().
142
+ * Throws if the contact is not a member — use removeContactFromGroupIfPresent for idempotent.
143
+ */
144
+ removeContactFromGroup(groupId: string, contactId: string): MajikContactGroup;
145
+ /**
146
+ * Idempotent variant — does not throw if the contact is not a member.
147
+ */
148
+ removeContactFromGroupIfPresent(groupId: string, contactId: string): MajikContactGroup;
149
+ /**
150
+ * Moves a contact from one group to another atomically.
151
+ * Throws if the contact is not a member of the source group.
152
+ */
153
+ moveContactBetweenGroups(contactId: string, fromGroupId: string, toGroupId: string): void;
154
+ /**
155
+ * Returns all hydrated MajikContact instances in the given group.
156
+ * Contacts removed from the directory since last save are silently skipped.
157
+ */
158
+ getContactsInGroup(groupId: string): MajikContact[];
159
+ /**
160
+ * Returns hydrated contacts in the group, sorted by label (or ID if no label).
161
+ */
162
+ getContactsInGroupSorted(groupId: string): MajikContact[];
163
+ /**
164
+ * Returns true if the contact is a member of the given group.
165
+ */
166
+ isContactInGroup(groupId: string, contactId: string): boolean;
167
+ /**
168
+ * Returns all groups the contact belongs to.
169
+ */
170
+ getGroupsForContact(contactId: string): MajikContactGroup[];
171
+ /**
172
+ * Returns all group IDs the contact belongs to.
173
+ */
174
+ getGroupIdsForContact(contactId: string): string[];
175
+ /**
176
+ * Adds the contact to the Favorites group (idempotent).
177
+ */
178
+ addToFavorites(contactId: string): MajikContactGroup;
179
+ /**
180
+ * Removes the contact from the Favorites group (idempotent).
181
+ */
182
+ removeFromFavorites(contactId: string): MajikContactGroup;
183
+ /**
184
+ * Returns true if the contact is in the Favorites group.
185
+ */
186
+ isFavorite(contactId: string): boolean;
187
+ /**
188
+ * Returns true if the contact is in the Blocked group.
189
+ */
190
+ isContactBlocked(contactId: string): boolean;
191
+ /**
192
+ * Returns the Favorites system group instance.
193
+ */
194
+ getFavoritesGroup(): MajikContactGroup;
195
+ /**
196
+ * Returns the Blocked system group instance.
197
+ */
198
+ getBlockedGroup(): MajikContactGroup;
199
+ /**
200
+ * Returns all contacts in the Favorites group as hydrated MajikContact instances.
201
+ */
202
+ getFavoriteContacts(): MajikContact[];
203
+ /**
204
+ * Returns all contacts in the Blocked group as hydrated MajikContact instances.
205
+ */
206
+ getBlockedContacts(): MajikContact[];
207
+ /**
208
+ * Clears both the directory and all group memberships.
209
+ * System groups are preserved (re-bootstrapped by the group manager).
210
+ */
211
+ clear(): this;
212
+ /**
213
+ * Serializes both the directory and all groups into a single unified payload.
214
+ * This is what MajikMessage.toJSON() should persist.
215
+ */
216
+ toJSON(): Promise<MajikContactManagerJSON>;
217
+ /**
218
+ * Restores a MajikContactManager from a unified serialized payload.
219
+ *
220
+ * Restoration order:
221
+ * 1. Restore the directory (contacts + crypto keys)
222
+ * 2. Restore groups via the group manager
223
+ * 3. Silently strip any group member IDs that no longer exist in the
224
+ * restored directory (orphan pruning) — guards against data drift
225
+ * between directory and group state across serialization rounds
226
+ *
227
+ * @param data The payload produced by toJSON().
228
+ * @param KEY_ALGO The WebCrypto algorithm descriptor used to import
229
+ * public keys — passed through to the directory's fromJSON.
230
+ */
231
+ static fromJSON(data: MajikContactManagerJSON, KEY_ALGO: KeyAlgorithm | EcKeyImportParams | {
232
+ name: string;
233
+ }): Promise<MajikContactManager>;
234
+ /**
235
+ * Walks every group and removes any member ID not present in the directory.
236
+ * Operates directly on the group instances — no re-serialization needed.
237
+ */
238
+ private static pruneOrphanedMembers;
239
+ private assertGroupManagerInstance;
240
+ }
@@ -0,0 +1,449 @@
1
+ /* -------------------------------
2
+ * Types
3
+ * ------------------------------- */
4
+ import { MajikContactDirectory } from "./majik-contact-directory";
5
+ import { MajikContactGroupManager } from "./majik-contact-groups";
6
+ import { MajikContactManagerError } from "./errors";
7
+ /* -------------------------------
8
+ * MajikContactManager Class
9
+ * ------------------------------- */
10
+ /**
11
+ * Unified facade over MajikContactDirectory and MajikContactGroupManager.
12
+ *
13
+ * Responsibilities:
14
+ * - Owns both the directory and the group manager as a single cohesive unit
15
+ * - Proxies all directory methods so MajikMessage call sites need only change
16
+ * `contactDirectory` → `contacts` with no logic changes
17
+ * - Wires lifecycle hooks automatically so callers can never forget them:
18
+ * • removeContact() → always calls groups.handleContactRemoved()
19
+ * • blockContact() → always syncs the Blocked system group
20
+ * • unblockContact() → always syncs the Blocked system group
21
+ * - Exposes the group manager via `.group` for all group-specific operations
22
+ * - Serializes both directory and groups into one unified payload for
23
+ * MajikMessage.toJSON() / MajikMessage.fromJSON()
24
+ *
25
+ * Construction:
26
+ * - Pass nothing → fresh directory + fresh group manager (new session)
27
+ * - Pass a directory → wraps it, creates a fresh group manager bound to it
28
+ * - Pass both → fully restores a prior session (used by fromJSON)
29
+ */
30
+ export class MajikContactManager {
31
+ directory;
32
+ groupManager;
33
+ constructor(directory, groupManager) {
34
+ this.directory = directory ?? new MajikContactDirectory();
35
+ if (groupManager) {
36
+ this.assertGroupManagerInstance(groupManager);
37
+ this.groupManager = groupManager;
38
+ }
39
+ else {
40
+ this.groupManager = new MajikContactGroupManager(this.directory);
41
+ }
42
+ }
43
+ /* ================================
44
+ * Group Manager Access
45
+ * ================================ */
46
+ /**
47
+ * Direct access to the full MajikContactGroupManager API.
48
+ * Use for all group-specific operations:
49
+ * manager.group.addToFavorites(contactId)
50
+ * manager.group.createGroup(id, name)
51
+ * manager.group.getContactsInGroup(groupId)
52
+ * etc.
53
+ */
54
+ get group() {
55
+ return this.groupManager;
56
+ }
57
+ /**
58
+ * Direct access to the underlying MajikContactDirectory.
59
+ * Prefer the proxied methods on this class over accessing the directory
60
+ * directly — they keep group state in sync automatically.
61
+ */
62
+ get directory_() {
63
+ return this.directory;
64
+ }
65
+ /* ================================
66
+ * Contact Management (Proxied)
67
+ * All signatures are intentionally identical to MajikContactDirectory
68
+ * so MajikMessage call sites require zero logic changes.
69
+ * ================================ */
70
+ addContact(contact) {
71
+ this.directory.addContact(contact);
72
+ return this;
73
+ }
74
+ addContacts(contacts) {
75
+ this.directory.addContacts(contacts);
76
+ return this;
77
+ }
78
+ /**
79
+ * Removes a contact from the directory and automatically removes them
80
+ * from every group they belong to via the group manager hook.
81
+ * The two operations are always atomic from the caller's perspective.
82
+ */
83
+ removeContact(id) {
84
+ const result = this.directory.removeContact(id);
85
+ if (result.success) {
86
+ this.groupManager.handleContactRemoved(id);
87
+ }
88
+ return result;
89
+ }
90
+ getContact(id) {
91
+ return this.directory.getContact(id);
92
+ }
93
+ getContactByFingerprint(fingerprint) {
94
+ return this.directory.getContactByFingerprint(fingerprint);
95
+ }
96
+ async getContactByPublicKeyBase64(publicKeyBase64) {
97
+ return this.directory.getContactByPublicKeyBase64(publicKeyBase64);
98
+ }
99
+ hasContact(id) {
100
+ return this.directory.hasContact(id);
101
+ }
102
+ hasFingerprint(fingerprint) {
103
+ return this.directory.hasFingerprint(fingerprint);
104
+ }
105
+ async hasContactByPublicKeyBase64(publicKeyBase64) {
106
+ return this.directory.hasContactByPublicKeyBase64(publicKeyBase64);
107
+ }
108
+ listContacts(sortedByLabel = false, majikahOnly = false) {
109
+ return this.directory.listContacts(sortedByLabel, majikahOnly);
110
+ }
111
+ updateContactMeta(id, meta) {
112
+ return this.directory.updateContactMeta(id, meta);
113
+ }
114
+ /**
115
+ * Blocks a contact on the directory AND adds them to the system Blocked
116
+ * group — both sides are always kept in sync.
117
+ */
118
+ blockContact(id) {
119
+ const contact = this.directory.blockContact(id);
120
+ this.groupManager.addContactToGroupIfAbsent(this.groupManager.getBlockedGroup().id, id);
121
+ return contact;
122
+ }
123
+ /**
124
+ * Unblocks a contact on the directory AND removes them from the system
125
+ * Blocked group — both sides are always kept in sync.
126
+ */
127
+ unblockContact(id) {
128
+ const contact = this.directory.unblockContact(id);
129
+ this.groupManager.removeContactFromGroupIfPresent(this.groupManager.getBlockedGroup().id, id);
130
+ return contact;
131
+ }
132
+ setMajikahStatus(id, status) {
133
+ return this.directory.setMajikahStatus(id, status);
134
+ }
135
+ isMajikahRegistered(id) {
136
+ return this.directory.isMajikahRegistered(id);
137
+ }
138
+ isMajikahIdentityChecked(id) {
139
+ return this.directory.isMajikahIdentityChecked(id);
140
+ }
141
+ hasContactForEnvelope(envelope) {
142
+ return this.directory.hasContactForEnvelope(envelope);
143
+ }
144
+ /* ================================
145
+ * Group CRUD Pass-throughs
146
+ * ================================ */
147
+ /**
148
+ * Creates and registers a new user-defined group.
149
+ * Throws if a group with the same ID already exists.
150
+ */
151
+ createGroup(id, name, meta, initialMemberIds) {
152
+ return this.groupManager.createGroup(id, name, meta, initialMemberIds);
153
+ }
154
+ /**
155
+ * Registers an already-constructed MajikContactGroup instance.
156
+ * Throws if a group with the same ID already exists.
157
+ */
158
+ addGroup(group) {
159
+ this.groupManager.addGroup(group);
160
+ return this;
161
+ }
162
+ /**
163
+ * Removes a user group by ID.
164
+ * System groups (Favorites, Blocked) cannot be deleted.
165
+ */
166
+ removeGroup(id) {
167
+ return this.groupManager.removeGroup(id);
168
+ }
169
+ /**
170
+ * Returns a group by ID, or undefined if not found.
171
+ */
172
+ getGroup(id) {
173
+ return this.groupManager.getGroup(id);
174
+ }
175
+ /**
176
+ * Returns a group by ID. Throws if not found.
177
+ */
178
+ getGroupOrThrow(id) {
179
+ return this.groupManager.getGroupOrThrow(id);
180
+ }
181
+ /**
182
+ * Returns true if a group with the given ID exists.
183
+ */
184
+ hasGroup(id) {
185
+ return this.groupManager.hasGroup(id);
186
+ }
187
+ /**
188
+ * Returns all groups.
189
+ *
190
+ * @param includeSystem Include system groups (Favorites, Blocked). Default: true.
191
+ * @param sortedByName Sort results alphabetically by group name. Default: false.
192
+ */
193
+ listGroups(includeSystem = true, sortedByName = false) {
194
+ return this.groupManager.listGroups(includeSystem, sortedByName);
195
+ }
196
+ /**
197
+ * Returns only user-created groups (excludes Favorites and Blocked).
198
+ * Sorted alphabetically by name.
199
+ */
200
+ listUserGroups(sortedByName = true) {
201
+ return this.groupManager.listGroups(false, sortedByName);
202
+ }
203
+ /**
204
+ * Returns only system groups (Favorites and Blocked).
205
+ */
206
+ listSystemGroups() {
207
+ return this.groupManager.listGroups(true).filter((g) => g.isSystem);
208
+ }
209
+ /**
210
+ * Updates mutable metadata on a group (name, description).
211
+ * Name is locked on system groups — will throw if attempted.
212
+ */
213
+ updateGroupMeta(id, meta) {
214
+ return this.groupManager.updateGroupMeta(id, meta);
215
+ }
216
+ /* ================================
217
+ * Group Membership Pass-throughs
218
+ * ================================ */
219
+ /**
220
+ * Adds a contact to a group.
221
+ * Validates the contact exists in the directory.
222
+ * If the group is the system Blocked group, also calls contact.block().
223
+ * Throws if the contact is already a member — use addContactToGroupIfAbsent for idempotent.
224
+ */
225
+ addContactToGroup(groupId, contactId) {
226
+ return this.groupManager.addContactToGroup(groupId, contactId);
227
+ }
228
+ /**
229
+ * Idempotent variant — does not throw if the contact is already a member.
230
+ */
231
+ addContactToGroupIfAbsent(groupId, contactId) {
232
+ return this.groupManager.addContactToGroupIfAbsent(groupId, contactId);
233
+ }
234
+ /**
235
+ * Adds multiple contacts to a group in one call (all-or-nothing).
236
+ */
237
+ addContactsToGroup(groupId, contactIds) {
238
+ return this.groupManager.addContactsToGroup(groupId, contactIds);
239
+ }
240
+ /**
241
+ * Removes a contact from a group.
242
+ * If the group is the system Blocked group, also calls contact.unblock().
243
+ * Throws if the contact is not a member — use removeContactFromGroupIfPresent for idempotent.
244
+ */
245
+ removeContactFromGroup(groupId, contactId) {
246
+ return this.groupManager.removeContactFromGroup(groupId, contactId);
247
+ }
248
+ /**
249
+ * Idempotent variant — does not throw if the contact is not a member.
250
+ */
251
+ removeContactFromGroupIfPresent(groupId, contactId) {
252
+ return this.groupManager.removeContactFromGroupIfPresent(groupId, contactId);
253
+ }
254
+ /**
255
+ * Moves a contact from one group to another atomically.
256
+ * Throws if the contact is not a member of the source group.
257
+ */
258
+ moveContactBetweenGroups(contactId, fromGroupId, toGroupId) {
259
+ return this.groupManager.moveContact(contactId, fromGroupId, toGroupId);
260
+ }
261
+ /* ================================
262
+ * Group Query Pass-throughs
263
+ * ================================ */
264
+ /**
265
+ * Returns all hydrated MajikContact instances in the given group.
266
+ * Contacts removed from the directory since last save are silently skipped.
267
+ */
268
+ getContactsInGroup(groupId) {
269
+ return this.groupManager.getContactsInGroup(groupId);
270
+ }
271
+ /**
272
+ * Returns hydrated contacts in the group, sorted by label (or ID if no label).
273
+ */
274
+ getContactsInGroupSorted(groupId) {
275
+ return this.groupManager.getContactsInGroupSorted(groupId);
276
+ }
277
+ /**
278
+ * Returns true if the contact is a member of the given group.
279
+ */
280
+ isContactInGroup(groupId, contactId) {
281
+ return this.groupManager.isContactInGroup(groupId, contactId);
282
+ }
283
+ /**
284
+ * Returns all groups the contact belongs to.
285
+ */
286
+ getGroupsForContact(contactId) {
287
+ return this.groupManager.getGroupsForContact(contactId);
288
+ }
289
+ /**
290
+ * Returns all group IDs the contact belongs to.
291
+ */
292
+ getGroupIdsForContact(contactId) {
293
+ return this.groupManager.getGroupIdsForContact(contactId);
294
+ }
295
+ /* ================================
296
+ * System Group Convenience Pass-throughs
297
+ * ================================ */
298
+ /**
299
+ * Adds the contact to the Favorites group (idempotent).
300
+ */
301
+ addToFavorites(contactId) {
302
+ return this.groupManager.addToFavorites(contactId);
303
+ }
304
+ /**
305
+ * Removes the contact from the Favorites group (idempotent).
306
+ */
307
+ removeFromFavorites(contactId) {
308
+ return this.groupManager.removeFromFavorites(contactId);
309
+ }
310
+ /**
311
+ * Returns true if the contact is in the Favorites group.
312
+ */
313
+ isFavorite(contactId) {
314
+ return this.groupManager.isFavorite(contactId);
315
+ }
316
+ /**
317
+ * Returns true if the contact is in the Blocked group.
318
+ */
319
+ isContactBlocked(contactId) {
320
+ return this.groupManager.isBlocked(contactId);
321
+ }
322
+ /**
323
+ * Returns the Favorites system group instance.
324
+ */
325
+ getFavoritesGroup() {
326
+ return this.groupManager.getFavoritesGroup();
327
+ }
328
+ /**
329
+ * Returns the Blocked system group instance.
330
+ */
331
+ getBlockedGroup() {
332
+ return this.groupManager.getBlockedGroup();
333
+ }
334
+ /**
335
+ * Returns all contacts in the Favorites group as hydrated MajikContact instances.
336
+ */
337
+ getFavoriteContacts() {
338
+ return this.groupManager.getContactsInGroup(this.groupManager.getFavoritesGroup().id);
339
+ }
340
+ /**
341
+ * Returns all contacts in the Blocked group as hydrated MajikContact instances.
342
+ */
343
+ getBlockedContacts() {
344
+ return this.groupManager.getContactsInGroup(this.groupManager.getBlockedGroup().id);
345
+ }
346
+ /* ================================
347
+ * Directory Clear
348
+ * ================================ */
349
+ /**
350
+ * Clears both the directory and all group memberships.
351
+ * System groups are preserved (re-bootstrapped by the group manager).
352
+ */
353
+ clear() {
354
+ const allContactIds = this.directory.listContacts().map((c) => c.id);
355
+ this.directory.clear();
356
+ // Notify the group manager for every contact so the reverse index
357
+ // and group memberships are cleaned up properly
358
+ allContactIds.forEach((id) => this.groupManager.handleContactRemoved(id));
359
+ return this;
360
+ }
361
+ /* ================================
362
+ * Serialization / Persistence
363
+ * ================================ */
364
+ /**
365
+ * Serializes both the directory and all groups into a single unified payload.
366
+ * This is what MajikMessage.toJSON() should persist.
367
+ */
368
+ async toJSON() {
369
+ return {
370
+ contacts: await this.directory.toJSON(),
371
+ groups: this.groupManager.toJSON(),
372
+ };
373
+ }
374
+ /**
375
+ * Restores a MajikContactManager from a unified serialized payload.
376
+ *
377
+ * Restoration order:
378
+ * 1. Restore the directory (contacts + crypto keys)
379
+ * 2. Restore groups via the group manager
380
+ * 3. Silently strip any group member IDs that no longer exist in the
381
+ * restored directory (orphan pruning) — guards against data drift
382
+ * between directory and group state across serialization rounds
383
+ *
384
+ * @param data The payload produced by toJSON().
385
+ * @param KEY_ALGO The WebCrypto algorithm descriptor used to import
386
+ * public keys — passed through to the directory's fromJSON.
387
+ */
388
+ static async fromJSON(data, KEY_ALGO) {
389
+ if (!data || typeof data !== "object") {
390
+ throw new MajikContactManagerError("fromJSON: invalid payload — expected { contacts, groups }");
391
+ }
392
+ if (!data.contacts) {
393
+ throw new MajikContactManagerError("fromJSON: missing required field 'contacts'");
394
+ }
395
+ if (!data.groups) {
396
+ throw new MajikContactManagerError("fromJSON: missing required field 'groups'");
397
+ }
398
+ // Step 1 — restore directory
399
+ let directory;
400
+ try {
401
+ directory = new MajikContactDirectory();
402
+ await directory.fromJSON(data.contacts);
403
+ }
404
+ catch (err) {
405
+ throw new MajikContactManagerError("fromJSON: failed to restore contact directory", err);
406
+ }
407
+ // Step 2 — restore group manager bound to the restored directory
408
+ let groupManager;
409
+ try {
410
+ groupManager = new MajikContactGroupManager(directory);
411
+ groupManager.fromJSON(data.groups);
412
+ }
413
+ catch (err) {
414
+ throw new MajikContactManagerError("fromJSON: failed to restore group manager", err);
415
+ }
416
+ // Step 3 — silently prune orphaned member IDs from every group
417
+ // An orphan is a contact ID that exists in a group but is absent from
418
+ // the restored directory. This can happen if a contact was removed
419
+ // between two save cycles or data was partially corrupted.
420
+ MajikContactManager.pruneOrphanedMembers(directory, groupManager);
421
+ return new MajikContactManager(directory, groupManager);
422
+ }
423
+ /**
424
+ * Walks every group and removes any member ID not present in the directory.
425
+ * Operates directly on the group instances — no re-serialization needed.
426
+ */
427
+ static pruneOrphanedMembers(directory, groupManager) {
428
+ const allGroups = groupManager.listGroups(true); // include system groups
429
+ for (const group of allGroups) {
430
+ const orphans = group
431
+ .listMemberIds()
432
+ .filter((id) => !directory.hasContact(id));
433
+ for (const orphanId of orphans) {
434
+ // Use the idempotent variant — safe even if the index is already clean
435
+ group.removeMemberIfPresent(orphanId);
436
+ // Also clean up the reverse index on the group manager
437
+ groupManager.handleContactRemoved(orphanId);
438
+ }
439
+ }
440
+ }
441
+ /* ================================
442
+ * Assertions
443
+ * ================================ */
444
+ assertGroupManagerInstance(gm) {
445
+ if (!gm || !(gm instanceof MajikContactGroupManager)) {
446
+ throw new MajikContactManagerError("groupManager must be a valid MajikContactGroupManager instance");
447
+ }
448
+ }
449
+ }