@pmate/chat-sdk 0.12.0-next.73 → 0.12.0-next.74

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.
@@ -145,4 +145,7 @@ export interface Chat {
145
145
  threadId: string;
146
146
  }>;
147
147
  updateGroup(threadId: string, changes: GroupChanges): Promise<ChatGroup>;
148
+ dissolveGroup(threadId: string, options?: {
149
+ expectedRevision?: string;
150
+ }): Promise<ChatGroup>;
148
151
  }
@@ -52,7 +52,7 @@ export type ChatMessage = Readonly<{
52
52
  mentions?: readonly ChatMention[];
53
53
  recipientCount?: number;
54
54
  system?: Readonly<{
55
- type: "group-created" | "group-updated" | "contact-updated";
55
+ type: "group-created" | "group-updated" | "group-dissolved" | "contact-updated";
56
56
  title?: string;
57
57
  }>;
58
58
  }>;
@@ -60,11 +60,17 @@ export type ChatGroup = Readonly<{
60
60
  groupId: string;
61
61
  teamId: string;
62
62
  ownerId: string;
63
+ revision: string;
64
+ } & ({
65
+ status: "active";
63
66
  title: string;
64
67
  avatar?: string;
65
68
  memberIds: readonly string[];
66
- revision: string;
67
- }>;
69
+ } | {
70
+ status: "dissolved";
71
+ dissolvedBy?: string;
72
+ dissolvedAt?: number;
73
+ })>;
68
74
  export type ConversationInfo = Readonly<{
69
75
  kind: "dm" | "group";
70
76
  targetId: string;
@@ -11,8 +11,10 @@ export type OutgoingContent = Exclude<RuntimeContent, {
11
11
  groupCreate: unknown;
12
12
  } | {
13
13
  groupUpdate: unknown;
14
+ } | {
15
+ groupDissolve: unknown;
14
16
  }>;
15
- import type { ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
17
+ import type { ChatGroup, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
16
18
  export type { ChatGroup, ConversationInfo, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
17
19
  export type MessageQuery = {
18
20
  earlier?: boolean;
@@ -94,15 +96,7 @@ export declare class ChatEngine {
94
96
  getVoiceAttachment(threadId: string, messageId: string): Promise<import("../runtime/durableSender.js").VoiceAttachmentLease>;
95
97
  getGroup(threadId: string, options?: {
96
98
  refresh?: boolean;
97
- }): Promise<Readonly<{
98
- groupId: string;
99
- teamId: string;
100
- ownerId: string;
101
- title: string;
102
- avatar?: string;
103
- memberIds: readonly string[];
104
- revision: string;
105
- }>>;
99
+ }): Promise<ChatGroup>;
106
100
  createGroup(input: {
107
101
  groupId?: string;
108
102
  title: string;
@@ -112,20 +106,25 @@ export declare class ChatEngine {
112
106
  groupId: string;
113
107
  teamId: string;
114
108
  ownerId: string;
109
+ revision: string;
110
+ status: "active";
115
111
  title: string;
116
112
  avatar?: string;
117
113
  memberIds: readonly string[];
118
- revision: string;
119
- }>;
120
- updateGroup(threadId: string, changes: GroupChanges): Promise<Readonly<{
114
+ } | {
115
+ threadId: string;
121
116
  groupId: string;
122
117
  teamId: string;
123
118
  ownerId: string;
124
- title: string;
125
- avatar?: string;
126
- memberIds: readonly string[];
127
119
  revision: string;
128
- }>>;
120
+ status: "dissolved";
121
+ dissolvedBy?: string;
122
+ dissolvedAt?: number;
123
+ }>;
124
+ updateGroup(threadId: string, changes: GroupChanges): Promise<ChatGroup>;
125
+ dissolveGroup(threadId: string, options?: {
126
+ expectedRevision?: string;
127
+ }): Promise<ChatGroup>;
129
128
  revokeAccess(): Promise<void>;
130
129
  close(): Promise<void>;
131
130
  }
@@ -201,8 +201,8 @@ export class ChatEngine {
201
201
  }
202
202
  sendMessage(threadId, content, options = {}) {
203
203
  return this.threadOperation(threadId, async () => {
204
- if (content && ("groupCreate" in content || "groupUpdate" in content))
205
- throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
204
+ if (content && ("groupCreate" in content || "groupUpdate" in content || "groupDissolve" in content))
205
+ throw new ChatError("invalid-input", "Use group lifecycle methods for group changes");
206
206
  const result = await this.advanced.sendMessage(threadId, content, options);
207
207
  return { id: result.hash, timestamp: result.t };
208
208
  });
@@ -275,6 +275,7 @@ export class ChatEngine {
275
275
  });
276
276
  }
277
277
  updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
278
+ dissolveGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.dissolveGroup(threadId, options))); }
278
279
  revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
279
280
  close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.threadSubscribers.clear(); this.openQueue.clear(); this.views.clear(); this.summary = undefined; this.present = createMessagePresenter(); this.messageLists = new WeakMap(); this.groups = new WeakMap(); this.redactedQuotes = new WeakMap(); }); }
280
281
  }
@@ -45,7 +45,7 @@ export function createPublicChat(options) {
45
45
  },
46
46
  getVoiceAttachment: (id, messageId, options = {}) => read(() => engine.getVoiceAttachment(id, messageId), options, lease => lease.release()),
47
47
  getGroup: (id, options = {}) => read(() => engine.getGroup(id), options), refreshGroup: (id, options = {}) => read(() => engine.getGroup(id, { refresh: true }), options),
48
- createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes),
48
+ createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes), dissolveGroup: (id, options) => engine.dissolveGroup(id, options),
49
49
  });
50
50
  registerChat(chat, engine);
51
51
  return chat;
@@ -51,7 +51,7 @@ function content(message) {
51
51
  }
52
52
  else if (body.kind === "group") {
53
53
  result.kind = "system";
54
- result.system = { type: body.action === "groupCreate" ? "group-created" : "group-updated", title: text(object(body.payload).title) || undefined };
54
+ result.system = { type: body.action === "groupCreate" ? "group-created" : body.action === "groupDissolve" ? "group-dissolved" : "group-updated", title: text(object(body.payload).title) || undefined };
55
55
  }
56
56
  else if (file) {
57
57
  result.kind = "attachment";
@@ -123,6 +123,11 @@ export declare class ChatClient {
123
123
  title?: string;
124
124
  avatar?: string;
125
125
  }): Promise<ChatRuntimeMessage>;
126
+ dissolveGroup(input: {
127
+ groupId: string;
128
+ teamId?: string;
129
+ expectedRevision: string;
130
+ }): Promise<ChatRuntimeMessage>;
126
131
  sendGroup(input: {
127
132
  groupId: string;
128
133
  teamId?: string;
@@ -252,6 +252,12 @@ export class ChatClient {
252
252
  addMembers: input.addMembers ?? [], removeMembers: input.removeMembers ?? [], ...(input.title !== undefined ? { title: input.title } : {}), ...(input.avatar !== undefined ? { avatar: input.avatar } : {}) },
253
253
  });
254
254
  }
255
+ async dissolveGroup(input) {
256
+ return this.sendChat(input.groupId, {
257
+ kind: "group", threadKind: "group", action: "groupDissolve",
258
+ payload: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), expectedRevision: input.expectedRevision },
259
+ });
260
+ }
255
261
  async sendGroup(input) {
256
262
  return this.sendChat(input.groupId, {
257
263
  kind: "message",
@@ -149,6 +149,9 @@ export declare class ChatSession<T extends StoredMessage> {
149
149
  title?: string;
150
150
  avatar?: string;
151
151
  }): Promise<ChatGroup>;
152
+ dissolveGroup(target: ChatConversation | string, options?: {
153
+ expectedRevision?: string;
154
+ }): Promise<ChatGroup>;
152
155
  sendMessage(threadId: string, input: OutgoingContent, options?: {
153
156
  waitFor?: "accepted" | "delivered";
154
157
  }): Promise<ChatRuntimeMessage>;
@@ -403,6 +403,16 @@ export class ChatSession {
403
403
  await this.sendMessage(thread, { groupUpdate: { ...changes, expectedRevision: changes.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
404
404
  return this.groups.after(thread, previous.versionNumber + 1);
405
405
  }
406
+ async dissolveGroup(target, options = {}) {
407
+ const thread = typeof target === "string" ? await this.resolveConversation(target) : target;
408
+ const previous = await this.getGroup(thread);
409
+ if (previous.status === "dissolved")
410
+ return previous;
411
+ if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
412
+ throw new Error("INVALID_GROUP_VERSION");
413
+ await this.sendMessage(thread, { groupDissolve: { expectedRevision: options.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
414
+ return this.groups.after(thread, previous.versionNumber + 1);
415
+ }
406
416
  sendMessage(thread, input, options = {}) {
407
417
  this.assertOpen();
408
418
  if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
@@ -434,8 +444,10 @@ export class ChatSession {
434
444
  await this.conversations.put(thread);
435
445
  this.assertOpen();
436
446
  // Recipients are SDK-owned metadata, never an App-supplied roster.
437
- if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate) {
447
+ if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate && !input.groupDissolve) {
438
448
  const group = await this.getGroup(thread);
449
+ if (group.status === "dissolved")
450
+ throw new Error("GROUP_DISSOLVED");
439
451
  this.assertOpen();
440
452
  thread = { ...thread, memberIds: group.memberIds };
441
453
  }
@@ -876,7 +876,7 @@ export class DurableChatSender {
876
876
  }
877
877
  }
878
878
  }
879
- function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
879
+ function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate" || body.action === "groupDissolve"); }
880
880
  function commandId(scope, version, threadId, message) {
881
881
  return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
882
882
  }
@@ -2,7 +2,7 @@ import { type ResponseCacheAccess } from "./responseCache.js";
2
2
  import { type MessageScope } from "./messageStore.js";
3
3
  import { type ChatConversation } from "./outgoingContent.js";
4
4
  import type { SocialGroup } from "./socialReadClient.js";
5
- export type ChatGroup = {
5
+ export type ActiveChatGroup = {
6
6
  groupId: string;
7
7
  teamId: string;
8
8
  ownerId: string;
@@ -11,7 +11,19 @@ export type ChatGroup = {
11
11
  memberIds: readonly string[];
12
12
  revision: string;
13
13
  versionNumber: number;
14
+ status: "active";
14
15
  };
16
+ export type DissolvedChatGroup = {
17
+ groupId: string;
18
+ teamId: string;
19
+ ownerId: string;
20
+ revision: string;
21
+ versionNumber: number;
22
+ status: "dissolved";
23
+ dissolvedBy?: string;
24
+ dissolvedAt?: number;
25
+ };
26
+ export type ChatGroup = ActiveChatGroup | DissolvedChatGroup;
15
27
  export type GroupSnapshot = {
16
28
  data?: ChatGroup;
17
29
  loading: boolean;
@@ -106,12 +106,19 @@ export class GroupClient {
106
106
  }
107
107
  normalize(value, entry) {
108
108
  const scope = this.options.scope;
109
- if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1 || !Array.isArray(value.members) || !value.members.length || value.members.length > 256 || !value.members.every(validMessageIdentifier) || !value.members.includes(value.ownerId) || !value.members.includes(scope.profileId) || typeof value.title !== "string" || value.avatar !== undefined && value.avatar !== null && typeof value.avatar !== "string")
109
+ if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1)
110
110
  throw new Error("INVALID_GROUP_METADATA");
111
- return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, title: value.title, ...(value.avatar ? { avatar: value.avatar } : {}), memberIds: Object.freeze([...new Set(value.members)].sort()), revision: value.revision, versionNumber: value.versionNumber });
111
+ if (value.status === "dissolved") {
112
+ if (value.dissolvedBy !== undefined && !validMessageIdentifier(value.dissolvedBy) || value.dissolvedAt !== undefined && (!Number.isSafeInteger(value.dissolvedAt) || value.dissolvedAt < 0))
113
+ throw new Error("INVALID_GROUP_METADATA");
114
+ return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, revision: value.revision, versionNumber: value.versionNumber, status: "dissolved", ...(value.dissolvedBy ? { dissolvedBy: value.dissolvedBy } : {}), ...(value.dissolvedAt === undefined ? {} : { dissolvedAt: value.dissolvedAt }) });
115
+ }
116
+ if (value.status !== undefined && value.status !== "active" || !Array.isArray(value.members) || !value.members.length || value.members.length > 256 || !value.members.every(validMessageIdentifier) || !value.members.includes(value.ownerId) || !value.members.includes(scope.profileId) || typeof value.title !== "string" || value.avatar !== undefined && value.avatar !== null && typeof value.avatar !== "string")
117
+ throw new Error("INVALID_GROUP_METADATA");
118
+ return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, title: value.title, ...(value.avatar ? { avatar: value.avatar } : {}), memberIds: Object.freeze([...new Set(value.members)].sort()), revision: value.revision, versionNumber: value.versionNumber, status: "active" });
112
119
  }
113
120
  cached(value, entry) {
114
- return this.normalize({ appId: this.options.scope.appId, groupId: value?.groupId, teamId: value?.teamId, ownerId: value?.ownerId, title: value?.title, avatar: value?.avatar, members: value?.memberIds, revision: value?.revision, versionNumber: value?.versionNumber }, entry);
121
+ return this.normalize(value.status === "dissolved" ? { appId: this.options.scope.appId, ...value } : { appId: this.options.scope.appId, ...value, members: value.memberIds }, entry);
115
122
  }
116
123
  local(entry) {
117
124
  if (entry.loaded)
@@ -73,6 +73,11 @@ type ContentChoices = {
73
73
  avatar?: string;
74
74
  };
75
75
  };
76
+ groupDissolve: {
77
+ groupDissolve: {
78
+ expectedRevision: string;
79
+ };
80
+ };
76
81
  };
77
82
  export type OutgoingContent = {
78
83
  [K in keyof ContentChoices]: ContentChoices[K] & {
@@ -1,6 +1,6 @@
1
1
  import { parseChatFile } from "../fileMetadata.js";
2
2
  import { dmThreadId, groupThreadId } from "./thread.js";
3
- const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
3
+ const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate", "groupDissolve"];
4
4
  export function validMessageIdentifier(value) { return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f]/u.test(value); }
5
5
  function members(value) {
6
6
  if (!Array.isArray(value) || value.length > 256 || !value.every(validMessageIdentifier))
@@ -63,6 +63,11 @@ export function outgoingContent(scope, thread, input) {
63
63
  throw new Error("INVALID_GROUP_AVATAR");
64
64
  return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupUpdate", payload: { ...identity, groupId: thread.targetId, expectedRevision: update.expectedRevision, addMembers: add, removeMembers: remove, ...(update.title === undefined ? {} : { title: update.title.trim() }), ...(update.avatar === undefined ? {} : { avatar: update.avatar }) } } };
65
65
  }
66
+ if (input.groupDissolve) {
67
+ if (thread.kind !== "group" || !validMessageIdentifier(input.groupDissolve.expectedRevision))
68
+ throw new Error("INVALID_GROUP_DISSOLVE");
69
+ return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupDissolve", payload: { ...identity, groupId: thread.targetId, expectedRevision: input.groupDissolve.expectedRevision } } };
70
+ }
66
71
  if (thread.kind === "group" && !Array.isArray(thread.memberIds))
67
72
  throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
68
73
  const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
@@ -18,11 +18,14 @@ export type SocialGroup = {
18
18
  groupId: string;
19
19
  teamId?: string | null;
20
20
  ownerId: string;
21
- members: string[];
22
- title: string;
21
+ members?: readonly string[];
22
+ title?: string;
23
23
  avatar?: string | null;
24
24
  revision: string;
25
25
  versionNumber?: number;
26
+ status?: "active" | "dissolved";
27
+ dissolvedBy?: string;
28
+ dissolvedAt?: number;
26
29
  };
27
30
  export type SocialRelationship = {
28
31
  appId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.73",
3
+ "version": "0.12.0-next.74",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -53,6 +53,15 @@
53
53
  "publishConfig": {
54
54
  "access": "public"
55
55
  },
56
+ "scripts": {
57
+ "clean": "rimraf lib",
58
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
59
+ "prepack": "pnpm run build",
60
+ "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
61
+ "test": "vitest run",
62
+ "storybook": "storybook dev -p 6009",
63
+ "build-storybook": "storybook build --output-dir dist"
64
+ },
56
65
  "dependencies": {
57
66
  "@emoji-mart/data": "^1.2.1",
58
67
  "@emoji-mart/react": "^1.1.1",
@@ -68,24 +77,16 @@
68
77
  "devDependencies": {
69
78
  "@storybook/addon-docs": "^9.0.17",
70
79
  "@storybook/react-vite": "9.0.17",
71
- "@tailwindcss/vite": "^4.1.3",
72
- "@types/react": "^19.2.7",
73
- "@types/react-dom": "^19.2.3",
80
+ "@tailwindcss/vite": "catalog:",
81
+ "@types/react": "catalog:",
82
+ "@types/react-dom": "catalog:",
74
83
  "fake-indexeddb": "^6.2.5",
75
- "react": "^19.1.1",
76
- "react-dom": "^19.1.1",
84
+ "react": "catalog:",
85
+ "react-dom": "catalog:",
77
86
  "rimraf": "^6.1.2",
78
87
  "storybook": "^9.0.17",
79
- "tailwindcss": "^4.0.14",
88
+ "tailwindcss": "catalog:",
80
89
  "typescript": "5.9.2",
81
90
  "vitest": "^4.0.17"
82
- },
83
- "scripts": {
84
- "clean": "rimraf lib",
85
- "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
86
- "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
87
- "test": "vitest run",
88
- "storybook": "storybook dev -p 6009",
89
- "build-storybook": "storybook build --output-dir dist"
90
91
  }
91
- }
92
+ }