@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.
- package/dist/core/contacts/errors.d.ts +12 -0
- package/dist/core/contacts/errors.js +27 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +2 -8
- package/dist/core/contacts/majik-contact-directory.js +1 -11
- package/dist/core/contacts/majik-contact-groups.d.ts +185 -0
- package/dist/core/contacts/majik-contact-groups.js +557 -0
- package/dist/core/contacts/majik-contact-manager.d.ts +240 -0
- package/dist/core/contacts/majik-contact-manager.js +449 -0
- package/dist/core/contacts/majik-contact-migration.d.ts +27 -0
- package/dist/core/contacts/majik-contact-migration.js +84 -0
- package/dist/core/contacts/types.d.ts +11 -0
- package/dist/core/contacts/types.js +4 -0
- package/dist/majik-message.d.ts +162 -37
- package/dist/majik-message.js +292 -127
- package/package.json +4 -4
|
@@ -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
|
+
}
|