@unicitylabs/sphere-sdk 0.6.0 → 0.6.2

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.
@@ -29,6 +29,8 @@ interface GroupData {
29
29
  unreadCount?: number;
30
30
  lastMessageTime?: number;
31
31
  lastMessageText?: string;
32
+ /** Only admins and moderators can post; other members are read-only (NIP-29 "write-restricted" tag) */
33
+ writeRestricted?: boolean;
32
34
  /** When the current user joined this group locally (used to filter old events) */
33
35
  localJoinedAt?: number;
34
36
  }
@@ -61,11 +63,24 @@ interface GroupChatModuleConfig {
61
63
  /** Max reconnect attempts (default: 5) */
62
64
  maxReconnectAttempts?: number;
63
65
  }
66
+ interface GroupMessagesPage {
67
+ messages: GroupMessageData[];
68
+ hasMore: boolean;
69
+ oldestTimestamp: number | null;
70
+ }
71
+ interface GetGroupMessagesPageOptions {
72
+ /** Max messages to return (default: 20) */
73
+ limit?: number;
74
+ /** Return messages older than this timestamp */
75
+ before?: number;
76
+ }
64
77
  interface CreateGroupOptions {
65
78
  name: string;
66
79
  description?: string;
67
80
  picture?: string;
68
81
  visibility?: GroupVisibility;
82
+ /** Only admins and moderators can post; other members are read-only */
83
+ writeRestricted?: boolean;
69
84
  }
70
85
 
71
86
  /**
@@ -758,7 +773,7 @@ interface TrackedAddress extends TrackedAddressEntry {
758
773
  /** Primary nametag (from nametag cache, without @ prefix) */
759
774
  readonly nametag?: string;
760
775
  }
761
- type SphereEventType = 'transfer:incoming' | 'transfer:confirmed' | 'transfer:failed' | 'payment_request:incoming' | 'payment_request:accepted' | 'payment_request:rejected' | 'payment_request:paid' | 'payment_request:response' | 'message:dm' | 'message:read' | 'message:typing' | 'composing:started' | 'message:broadcast' | 'sync:started' | 'sync:completed' | 'sync:provider' | 'sync:error' | 'connection:changed' | 'nametag:registered' | 'nametag:recovered' | 'identity:changed' | 'address:activated' | 'address:hidden' | 'address:unhidden' | 'sync:remote-update' | 'groupchat:message' | 'groupchat:joined' | 'groupchat:left' | 'groupchat:kicked' | 'groupchat:group_deleted' | 'groupchat:updated' | 'groupchat:connection' | 'history:updated';
776
+ type SphereEventType = 'transfer:incoming' | 'transfer:confirmed' | 'transfer:failed' | 'payment_request:incoming' | 'payment_request:accepted' | 'payment_request:rejected' | 'payment_request:paid' | 'payment_request:response' | 'message:dm' | 'message:read' | 'message:typing' | 'composing:started' | 'message:broadcast' | 'sync:started' | 'sync:completed' | 'sync:provider' | 'sync:error' | 'connection:changed' | 'nametag:registered' | 'nametag:recovered' | 'identity:changed' | 'address:activated' | 'address:hidden' | 'address:unhidden' | 'sync:remote-update' | 'groupchat:message' | 'groupchat:joined' | 'groupchat:left' | 'groupchat:kicked' | 'groupchat:group_deleted' | 'groupchat:updated' | 'groupchat:connection' | 'groupchat:ready' | 'communications:ready' | 'history:updated';
762
777
  interface SphereEventMap {
763
778
  'transfer:incoming': IncomingTransfer;
764
779
  'transfer:confirmed': TransferResult;
@@ -858,6 +873,12 @@ interface SphereEventMap {
858
873
  'groupchat:connection': {
859
874
  connected: boolean;
860
875
  };
876
+ 'groupchat:ready': {
877
+ groupCount: number;
878
+ };
879
+ 'communications:ready': {
880
+ conversationCount: number;
881
+ };
861
882
  'history:updated': TransactionHistoryEntry;
862
883
  }
863
884
  type SphereEventHandler<T extends SphereEventType> = (data: SphereEventMap[T]) => void;
@@ -1385,6 +1406,14 @@ interface TransportProvider extends BaseProvider {
1385
1406
  * and resolves after EOSE (End Of Stored Events).
1386
1407
  */
1387
1408
  fetchPendingEvents?(): Promise<void>;
1409
+ /**
1410
+ * Register a handler to be called when the chat subscription receives EOSE
1411
+ * (End Of Stored Events), indicating that historical DMs have been delivered.
1412
+ * The handler fires at most once per subscription lifecycle.
1413
+ *
1414
+ * @returns Unsubscribe function
1415
+ */
1416
+ onChatReady?(handler: () => void): () => void;
1388
1417
  }
1389
1418
  /**
1390
1419
  * Payload for sending instant split bundles
@@ -2947,6 +2976,7 @@ declare class GroupChatModule {
2947
2976
  getGroups(): GroupData[];
2948
2977
  getGroup(groupId: string): GroupData | null;
2949
2978
  getMessages(groupId: string): GroupMessageData[];
2979
+ getMessagesPage(groupId: string, options?: GetGroupMessagesPageOptions): GroupMessagesPage;
2950
2980
  getMembers(groupId: string): GroupMemberData[];
2951
2981
  getMember(groupId: string, pubkey: string): GroupMemberData | null;
2952
2982
  getTotalUnreadCount(): number;
@@ -2962,6 +2992,12 @@ declare class GroupChatModule {
2962
2992
  */
2963
2993
  canModerateGroup(groupId: string): Promise<boolean>;
2964
2994
  isCurrentUserRelayAdmin(): Promise<boolean>;
2995
+ /**
2996
+ * Check if current user can write messages to a group.
2997
+ * For write-restricted groups, only admins/moderators can post.
2998
+ * For normal groups, any member can post.
2999
+ */
3000
+ canWriteToGroup(groupId: string): boolean;
2965
3001
  getCurrentUserRole(groupId: string): GroupRole | null;
2966
3002
  onMessage(handler: (message: GroupMessageData) => void): () => void;
2967
3003
  getRelayUrls(): string[];
@@ -29,6 +29,8 @@ interface GroupData {
29
29
  unreadCount?: number;
30
30
  lastMessageTime?: number;
31
31
  lastMessageText?: string;
32
+ /** Only admins and moderators can post; other members are read-only (NIP-29 "write-restricted" tag) */
33
+ writeRestricted?: boolean;
32
34
  /** When the current user joined this group locally (used to filter old events) */
33
35
  localJoinedAt?: number;
34
36
  }
@@ -61,11 +63,24 @@ interface GroupChatModuleConfig {
61
63
  /** Max reconnect attempts (default: 5) */
62
64
  maxReconnectAttempts?: number;
63
65
  }
66
+ interface GroupMessagesPage {
67
+ messages: GroupMessageData[];
68
+ hasMore: boolean;
69
+ oldestTimestamp: number | null;
70
+ }
71
+ interface GetGroupMessagesPageOptions {
72
+ /** Max messages to return (default: 20) */
73
+ limit?: number;
74
+ /** Return messages older than this timestamp */
75
+ before?: number;
76
+ }
64
77
  interface CreateGroupOptions {
65
78
  name: string;
66
79
  description?: string;
67
80
  picture?: string;
68
81
  visibility?: GroupVisibility;
82
+ /** Only admins and moderators can post; other members are read-only */
83
+ writeRestricted?: boolean;
69
84
  }
70
85
 
71
86
  /**
@@ -758,7 +773,7 @@ interface TrackedAddress extends TrackedAddressEntry {
758
773
  /** Primary nametag (from nametag cache, without @ prefix) */
759
774
  readonly nametag?: string;
760
775
  }
761
- type SphereEventType = 'transfer:incoming' | 'transfer:confirmed' | 'transfer:failed' | 'payment_request:incoming' | 'payment_request:accepted' | 'payment_request:rejected' | 'payment_request:paid' | 'payment_request:response' | 'message:dm' | 'message:read' | 'message:typing' | 'composing:started' | 'message:broadcast' | 'sync:started' | 'sync:completed' | 'sync:provider' | 'sync:error' | 'connection:changed' | 'nametag:registered' | 'nametag:recovered' | 'identity:changed' | 'address:activated' | 'address:hidden' | 'address:unhidden' | 'sync:remote-update' | 'groupchat:message' | 'groupchat:joined' | 'groupchat:left' | 'groupchat:kicked' | 'groupchat:group_deleted' | 'groupchat:updated' | 'groupchat:connection' | 'history:updated';
776
+ type SphereEventType = 'transfer:incoming' | 'transfer:confirmed' | 'transfer:failed' | 'payment_request:incoming' | 'payment_request:accepted' | 'payment_request:rejected' | 'payment_request:paid' | 'payment_request:response' | 'message:dm' | 'message:read' | 'message:typing' | 'composing:started' | 'message:broadcast' | 'sync:started' | 'sync:completed' | 'sync:provider' | 'sync:error' | 'connection:changed' | 'nametag:registered' | 'nametag:recovered' | 'identity:changed' | 'address:activated' | 'address:hidden' | 'address:unhidden' | 'sync:remote-update' | 'groupchat:message' | 'groupchat:joined' | 'groupchat:left' | 'groupchat:kicked' | 'groupchat:group_deleted' | 'groupchat:updated' | 'groupchat:connection' | 'groupchat:ready' | 'communications:ready' | 'history:updated';
762
777
  interface SphereEventMap {
763
778
  'transfer:incoming': IncomingTransfer;
764
779
  'transfer:confirmed': TransferResult;
@@ -858,6 +873,12 @@ interface SphereEventMap {
858
873
  'groupchat:connection': {
859
874
  connected: boolean;
860
875
  };
876
+ 'groupchat:ready': {
877
+ groupCount: number;
878
+ };
879
+ 'communications:ready': {
880
+ conversationCount: number;
881
+ };
861
882
  'history:updated': TransactionHistoryEntry;
862
883
  }
863
884
  type SphereEventHandler<T extends SphereEventType> = (data: SphereEventMap[T]) => void;
@@ -1385,6 +1406,14 @@ interface TransportProvider extends BaseProvider {
1385
1406
  * and resolves after EOSE (End Of Stored Events).
1386
1407
  */
1387
1408
  fetchPendingEvents?(): Promise<void>;
1409
+ /**
1410
+ * Register a handler to be called when the chat subscription receives EOSE
1411
+ * (End Of Stored Events), indicating that historical DMs have been delivered.
1412
+ * The handler fires at most once per subscription lifecycle.
1413
+ *
1414
+ * @returns Unsubscribe function
1415
+ */
1416
+ onChatReady?(handler: () => void): () => void;
1388
1417
  }
1389
1418
  /**
1390
1419
  * Payload for sending instant split bundles
@@ -2947,6 +2976,7 @@ declare class GroupChatModule {
2947
2976
  getGroups(): GroupData[];
2948
2977
  getGroup(groupId: string): GroupData | null;
2949
2978
  getMessages(groupId: string): GroupMessageData[];
2979
+ getMessagesPage(groupId: string, options?: GetGroupMessagesPageOptions): GroupMessagesPage;
2950
2980
  getMembers(groupId: string): GroupMemberData[];
2951
2981
  getMember(groupId: string, pubkey: string): GroupMemberData | null;
2952
2982
  getTotalUnreadCount(): number;
@@ -2962,6 +2992,12 @@ declare class GroupChatModule {
2962
2992
  */
2963
2993
  canModerateGroup(groupId: string): Promise<boolean>;
2964
2994
  isCurrentUserRelayAdmin(): Promise<boolean>;
2995
+ /**
2996
+ * Check if current user can write messages to a group.
2997
+ * For write-restricted groups, only admins/moderators can post.
2998
+ * For normal groups, any member can post.
2999
+ */
3000
+ canWriteToGroup(groupId: string): boolean;
2965
3001
  getCurrentUserRole(groupId: string): GroupRole | null;
2966
3002
  onMessage(handler: (message: GroupMessageData) => void): () => void;
2967
3003
  getRelayUrls(): string[];
@@ -8299,6 +8299,12 @@ var CommunicationsModule = class {
8299
8299
  this.unsubscribeComposing = deps.transport.onComposing?.((indicator) => {
8300
8300
  this.handleComposingIndicator(indicator);
8301
8301
  }) ?? null;
8302
+ if (deps.transport.onChatReady) {
8303
+ deps.transport.onChatReady(() => {
8304
+ const conversations = this.getConversations();
8305
+ deps.emitEvent("communications:ready", { conversationCount: conversations.size });
8306
+ });
8307
+ }
8302
8308
  }
8303
8309
  /**
8304
8310
  * Load messages from storage.
@@ -8945,6 +8951,7 @@ var GroupChatModule = class {
8945
8951
  await this.subscribeToJoinedGroups();
8946
8952
  }
8947
8953
  this.deps.emitEvent("groupchat:connection", { connected: true });
8954
+ this.deps.emitEvent("groupchat:ready", { groupCount: this.groups.size });
8948
8955
  } catch (error) {
8949
8956
  logger.error("GroupChat", "Failed to connect to relays", error);
8950
8957
  this.deps.emitEvent("groupchat:connection", { connected: false });
@@ -9063,17 +9070,23 @@ var GroupChatModule = class {
9063
9070
  const group = this.groups.get(groupId);
9064
9071
  if (!group) return;
9065
9072
  if (event.kind === NIP29_KINDS.GROUP_METADATA) {
9066
- if (!event.content || event.content.trim() === "") return;
9067
- try {
9068
- const metadata = JSON.parse(event.content);
9069
- group.name = metadata.name || group.name;
9070
- group.description = metadata.about || group.description;
9071
- group.picture = metadata.picture || group.picture;
9072
- group.updatedAt = event.created_at * 1e3;
9073
- this.groups.set(groupId, group);
9074
- this.persistGroups();
9075
- } catch {
9073
+ if (event.content && event.content.trim()) {
9074
+ try {
9075
+ const metadata = JSON.parse(event.content);
9076
+ group.name = metadata.name || group.name;
9077
+ group.description = metadata.about || group.description;
9078
+ group.picture = metadata.picture || group.picture;
9079
+ if (metadata["write-restricted"] === true) group.writeRestricted = true;
9080
+ else group.writeRestricted = void 0;
9081
+ } catch {
9082
+ }
9076
9083
  }
9084
+ for (const tag of event.tags) {
9085
+ if (tag[0] === "write-restricted") group.writeRestricted = true;
9086
+ }
9087
+ group.updatedAt = event.created_at * 1e3;
9088
+ this.groups.set(groupId, group);
9089
+ this.persistGroups();
9077
9090
  } else if (event.kind === NIP29_KINDS.GROUP_MEMBERS) {
9078
9091
  this.updateMembersFromEvent(groupId, event);
9079
9092
  } else if (event.kind === NIP29_KINDS.GROUP_ADMINS) {
@@ -9377,7 +9390,8 @@ var GroupChatModule = class {
9377
9390
  picture: options.picture,
9378
9391
  closed: true,
9379
9392
  private: isPrivate,
9380
- hidden: isPrivate
9393
+ hidden: isPrivate,
9394
+ ...options.writeRestricted ? { "write-restricted": true } : {}
9381
9395
  })
9382
9396
  });
9383
9397
  if (!eventId) return null;
@@ -9572,6 +9586,19 @@ var GroupChatModule = class {
9572
9586
  getMessages(groupId) {
9573
9587
  return (this.messages.get(groupId) || []).sort((a, b) => a.timestamp - b.timestamp);
9574
9588
  }
9589
+ getMessagesPage(groupId, options) {
9590
+ const limit = options?.limit ?? 20;
9591
+ const before = options?.before ?? Infinity;
9592
+ const groupMessages = this.messages.get(groupId) ?? [];
9593
+ const filtered = groupMessages.filter((m) => m.timestamp < before).sort((a, b) => b.timestamp - a.timestamp);
9594
+ const page = filtered.slice(0, limit);
9595
+ return {
9596
+ messages: page.reverse(),
9597
+ // chronological order
9598
+ hasMore: filtered.length > limit,
9599
+ oldestTimestamp: page.length > 0 ? page[0].timestamp : null
9600
+ };
9601
+ }
9575
9602
  getMembers(groupId) {
9576
9603
  return (this.members.get(groupId) || []).sort((a, b) => a.joinedAt - b.joinedAt);
9577
9604
  }
@@ -9678,6 +9705,17 @@ var GroupChatModule = class {
9678
9705
  const admins = await this.fetchRelayAdmins();
9679
9706
  return admins.has(myPubkey);
9680
9707
  }
9708
+ /**
9709
+ * Check if current user can write messages to a group.
9710
+ * For write-restricted groups, only admins/moderators can post.
9711
+ * For normal groups, any member can post.
9712
+ */
9713
+ canWriteToGroup(groupId) {
9714
+ const group = this.groups.get(groupId);
9715
+ if (!group) return false;
9716
+ if (!group.writeRestricted) return true;
9717
+ return this.isCurrentUserModerator(groupId);
9718
+ }
9681
9719
  getCurrentUserRole(groupId) {
9682
9720
  const myPubkey = this.getMyPublicKey();
9683
9721
  if (!myPubkey) return null;
@@ -10052,6 +10090,7 @@ var GroupChatModule = class {
10052
10090
  let description;
10053
10091
  let picture;
10054
10092
  let isPrivate = false;
10093
+ let writeRestricted = false;
10055
10094
  if (event.content && event.content.trim()) {
10056
10095
  try {
10057
10096
  const metadata = JSON.parse(event.content);
@@ -10059,6 +10098,7 @@ var GroupChatModule = class {
10059
10098
  description = metadata.about || metadata.description;
10060
10099
  picture = metadata.picture;
10061
10100
  isPrivate = metadata.private === true;
10101
+ if (metadata["write-restricted"] === true) writeRestricted = true;
10062
10102
  } catch {
10063
10103
  }
10064
10104
  }
@@ -10068,6 +10108,7 @@ var GroupChatModule = class {
10068
10108
  if (tag[0] === "picture" && tag[1]) picture = tag[1];
10069
10109
  if (tag[0] === "private") isPrivate = true;
10070
10110
  if (tag[0] === "public" && tag[1] === "false") isPrivate = true;
10111
+ if (tag[0] === "write-restricted") writeRestricted = true;
10071
10112
  }
10072
10113
  return {
10073
10114
  id: groupId,
@@ -10076,6 +10117,7 @@ var GroupChatModule = class {
10076
10117
  description,
10077
10118
  picture,
10078
10119
  visibility: isPrivate ? GroupVisibility.PRIVATE : GroupVisibility.PUBLIC,
10120
+ writeRestricted: writeRestricted || void 0,
10079
10121
  createdAt: event.created_at * 1e3
10080
10122
  };
10081
10123
  } catch {