@openmarket/rooms-client 0.4.2 → 0.5.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.
@@ -74,6 +74,9 @@ export declare class SidebarModel {
74
74
  constructor(deps: SidebarModelDeps);
75
75
  snapshot(): SidebarSnapshot;
76
76
  onUpdate(listener: () => void): () => void;
77
+ /** Apply a same-user realtime DM counter without refetching the full room
78
+ * directory. Username is the stable DM key used by the sidebar model. */
79
+ applyDmUnread(username: string, unread: number): void;
77
80
  /**
78
81
  * Post-mutation refresh: if a poll-triggered load is already in flight it
79
82
  * may predate the caller's writes, so wait it out and load AGAIN. Readers
@@ -112,6 +112,34 @@ export class SidebarModel {
112
112
  this.listeners.delete(listener);
113
113
  };
114
114
  }
115
+ /** Apply a same-user realtime DM counter without refetching the full room
116
+ * directory. Username is the stable DM key used by the sidebar model. */
117
+ applyDmUnread(username, unread) {
118
+ const key = normalizeDmUsername(username);
119
+ if (!key)
120
+ return;
121
+ const normalizedUnread = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0;
122
+ let found = false;
123
+ let changed = false;
124
+ const next = this.snapshotValue.dms.map((dm) => {
125
+ if (normalizeDmUsername(dm.username) !== key)
126
+ return dm;
127
+ found = true;
128
+ if (dm.unread === normalizedUnread)
129
+ return dm;
130
+ changed = true;
131
+ return { ...dm, unread: normalizedUnread };
132
+ });
133
+ if (!found && normalizedUnread > 0) {
134
+ next.push({ username: username.replace(/^@+/, ""), unread: normalizedUnread });
135
+ changed = true;
136
+ }
137
+ if (!changed)
138
+ return;
139
+ this.snapshotValue = { ...this.snapshotValue, dms: next };
140
+ for (const listener of this.listeners)
141
+ listener();
142
+ }
115
143
  /**
116
144
  * Post-mutation refresh: if a poll-triggered load is already in flight it
117
145
  * may predate the caller's writes, so wait it out and load AGAIN. Readers
@@ -160,3 +188,6 @@ export class SidebarModel {
160
188
  });
161
189
  }
162
190
  }
191
+ function normalizeDmUsername(username) {
192
+ return username.trim().replace(/^@+/, "").toLowerCase();
193
+ }
package/dist/client.d.ts CHANGED
@@ -242,6 +242,9 @@ export { workspaceRoomName } from "./names.js";
242
242
  export declare function listRooms(config: RoomClientConfig, request?: RoomListRequest): Promise<RoomListResult>;
243
243
  export declare function createRoom(config: RoomClientConfig, request: RoomCreateRequest): Promise<RoomCreateResult>;
244
244
  export declare function inviteRoom(config: RoomClientConfig, request: RoomInviteRequest): Promise<RoomInviteResult>;
245
+ /** Invite over an already-authenticated socket. Long-lived GUI/TUI sessions
246
+ * use this path so a one-shot invite cannot create a second connection. */
247
+ export declare function inviteRoomOnClient(client: RoomWsClient, request: RoomInviteRequest, timeoutMs?: number): Promise<RoomInvitedPayload>;
245
248
  export declare function removeRoomMember(config: RoomClientConfig, request: RoomRemoveMemberRequest): Promise<RoomRemoveMemberResult>;
246
249
  export declare function reportRoomTarget(config: RoomClientConfig, request: RoomReportRequest): Promise<RoomReportResult>;
247
250
  export declare function muteRoomMember(config: RoomClientConfig, request: RoomMuteRequest): Promise<RoomMuteResult>;
package/dist/client.js CHANGED
@@ -101,9 +101,7 @@ async function createRoomViaWebSocket(config, request) {
101
101
  export async function inviteRoom(config, request) {
102
102
  // `@user` and `user` are the same person: strip the typing syntax once,
103
103
  // before either transport (the relay resolves the bare name).
104
- const normalized = request.username
105
- ? { ...request, username: request.username.trim().replace(/^@+/, "") }
106
- : request;
104
+ const normalized = normalizeRoomInviteRequest(request);
107
105
  if (!config.webSocketCtor) {
108
106
  try {
109
107
  return await inviteRoomViaHttp(config, normalized);
@@ -119,24 +117,35 @@ async function inviteRoomViaWebSocket(config, request) {
119
117
  const client = new RoomWsClient(config);
120
118
  try {
121
119
  const auth = await client.connect();
122
- const requestId = randomUUID();
123
- client.send({
124
- type: RoomClientMessageType.INVITE,
125
- payload: {
126
- requestId,
127
- room: request.room,
128
- ...(request.username ? { username: request.username } : {}),
129
- ...(request.userId ? { userId: request.userId } : {}),
130
- },
131
- timestamp: Date.now(),
132
- });
133
- const invited = await client.waitFor(RoomServerMessageType.ROOM_INVITED, (msg) => msg.payload.requestId === requestId, undefined, requestId);
134
- return { auth, invite: invited.payload };
120
+ return { auth, invite: await inviteRoomOnClient(client, request) };
135
121
  }
136
122
  finally {
137
123
  client.close();
138
124
  }
139
125
  }
126
+ /** Invite over an already-authenticated socket. Long-lived GUI/TUI sessions
127
+ * use this path so a one-shot invite cannot create a second connection. */
128
+ export async function inviteRoomOnClient(client, request, timeoutMs) {
129
+ const normalized = normalizeRoomInviteRequest(request);
130
+ const requestId = randomUUID();
131
+ client.send({
132
+ type: RoomClientMessageType.INVITE,
133
+ payload: {
134
+ requestId,
135
+ room: normalized.room,
136
+ ...(normalized.username ? { username: normalized.username } : {}),
137
+ ...(normalized.userId ? { userId: normalized.userId } : {}),
138
+ },
139
+ timestamp: Date.now(),
140
+ });
141
+ const invited = await client.waitFor(RoomServerMessageType.ROOM_INVITED, (msg) => msg.payload.requestId === requestId, timeoutMs, requestId);
142
+ return invited.payload;
143
+ }
144
+ function normalizeRoomInviteRequest(request) {
145
+ return request.username
146
+ ? { ...request, username: request.username.trim().replace(/^@+/, "") }
147
+ : request;
148
+ }
140
149
  export async function removeRoomMember(config, request) {
141
150
  if (!config.webSocketCtor) {
142
151
  try {
@@ -330,6 +330,12 @@ export declare function listRoomChannelMembers(config: RoomSocialConfig, roomId:
330
330
  export declare function removeSpaceMember(config: RoomSocialConfig, spaceId: string, targetUserId: string): Promise<{
331
331
  removed?: boolean;
332
332
  }>;
333
+ /** Leave a server as the authenticated caller. This is deliberately separate
334
+ * from the manager-only member-removal route so clients never send their own
335
+ * userId as authorization data. */
336
+ export declare function leaveSpace(config: RoomSocialConfig, spaceId: string): Promise<{
337
+ left?: boolean;
338
+ }>;
333
339
  export declare function changeSpaceMemberRole(config: RoomSocialConfig, spaceId: string, targetUserId: string, role: "admin" | "librarian" | "member"): Promise<RoomSpaceMember>;
334
340
  /** Set (string) or clear (null) a member's per-space nickname; the store validates and enforces uniqueness. */
335
341
  export declare function setSpaceMemberNickname(config: RoomSocialConfig, spaceId: string, targetUserId: string, nickname: string | null): Promise<RoomSpaceMember>;
@@ -500,6 +500,14 @@ export async function listRoomChannelMembers(config, roomId, options = {}) {
500
500
  export async function removeSpaceMember(config, spaceId, targetUserId) {
501
501
  return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}`, { method: "DELETE" });
502
502
  }
503
+ /** Leave a server as the authenticated caller. This is deliberately separate
504
+ * from the manager-only member-removal route so clients never send their own
505
+ * userId as authorization data. */
506
+ export async function leaveSpace(config, spaceId) {
507
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/membership`, {
508
+ method: "DELETE",
509
+ });
510
+ }
503
511
  export async function changeSpaceMemberRole(config, spaceId, targetUserId, role) {
504
512
  const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/role`, { method: "PATCH", body: { role } });
505
513
  return result.member;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarket/rooms-client",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "OM Rooms protocol client: wire types, WebSocket + REST clients, and chat view-models. Browser-safe (no node:/bun: imports).",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -186,6 +186,30 @@ export class SidebarModel {
186
186
  };
187
187
  }
188
188
 
189
+ /** Apply a same-user realtime DM counter without refetching the full room
190
+ * directory. Username is the stable DM key used by the sidebar model. */
191
+ applyDmUnread(username: string, unread: number): void {
192
+ const key = normalizeDmUsername(username);
193
+ if (!key) return;
194
+ const normalizedUnread = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0;
195
+ let found = false;
196
+ let changed = false;
197
+ const next = this.snapshotValue.dms.map((dm) => {
198
+ if (normalizeDmUsername(dm.username) !== key) return dm;
199
+ found = true;
200
+ if (dm.unread === normalizedUnread) return dm;
201
+ changed = true;
202
+ return { ...dm, unread: normalizedUnread };
203
+ });
204
+ if (!found && normalizedUnread > 0) {
205
+ next.push({ username: username.replace(/^@+/, ""), unread: normalizedUnread });
206
+ changed = true;
207
+ }
208
+ if (!changed) return;
209
+ this.snapshotValue = { ...this.snapshotValue, dms: next };
210
+ for (const listener of this.listeners) listener();
211
+ }
212
+
189
213
  /**
190
214
  * Post-mutation refresh: if a poll-triggered load is already in flight it
191
215
  * may predate the caller's writes, so wait it out and load AGAIN. Readers
@@ -233,3 +257,7 @@ export class SidebarModel {
233
257
  });
234
258
  }
235
259
  }
260
+
261
+ function normalizeDmUsername(username: string): string {
262
+ return username.trim().replace(/^@+/, "").toLowerCase();
263
+ }
package/src/client.ts CHANGED
@@ -443,9 +443,7 @@ export async function inviteRoom(
443
443
  ): Promise<RoomInviteResult> {
444
444
  // `@user` and `user` are the same person: strip the typing syntax once,
445
445
  // before either transport (the relay resolves the bare name).
446
- const normalized: RoomInviteRequest = request.username
447
- ? { ...request, username: request.username.trim().replace(/^@+/, "") }
448
- : request;
446
+ const normalized = normalizeRoomInviteRequest(request);
449
447
  if (!config.webSocketCtor) {
450
448
  try {
451
449
  return await inviteRoomViaHttp(config, normalized);
@@ -463,29 +461,46 @@ async function inviteRoomViaWebSocket(
463
461
  const client = new RoomWsClient(config);
464
462
  try {
465
463
  const auth = await client.connect();
466
- const requestId = randomUUID();
467
- client.send({
468
- type: RoomClientMessageType.INVITE,
469
- payload: {
470
- requestId,
471
- room: request.room,
472
- ...(request.username ? { username: request.username } : {}),
473
- ...(request.userId ? { userId: request.userId } : {}),
474
- },
475
- timestamp: Date.now(),
476
- });
477
- const invited = await client.waitFor(
478
- RoomServerMessageType.ROOM_INVITED,
479
- (msg) => msg.payload.requestId === requestId,
480
- undefined,
481
- requestId,
482
- );
483
- return { auth, invite: invited.payload };
464
+ return { auth, invite: await inviteRoomOnClient(client, request) };
484
465
  } finally {
485
466
  client.close();
486
467
  }
487
468
  }
488
469
 
470
+ /** Invite over an already-authenticated socket. Long-lived GUI/TUI sessions
471
+ * use this path so a one-shot invite cannot create a second connection. */
472
+ export async function inviteRoomOnClient(
473
+ client: RoomWsClient,
474
+ request: RoomInviteRequest,
475
+ timeoutMs?: number,
476
+ ): Promise<RoomInvitedPayload> {
477
+ const normalized = normalizeRoomInviteRequest(request);
478
+ const requestId = randomUUID();
479
+ client.send({
480
+ type: RoomClientMessageType.INVITE,
481
+ payload: {
482
+ requestId,
483
+ room: normalized.room,
484
+ ...(normalized.username ? { username: normalized.username } : {}),
485
+ ...(normalized.userId ? { userId: normalized.userId } : {}),
486
+ },
487
+ timestamp: Date.now(),
488
+ });
489
+ const invited = await client.waitFor(
490
+ RoomServerMessageType.ROOM_INVITED,
491
+ (msg) => msg.payload.requestId === requestId,
492
+ timeoutMs,
493
+ requestId,
494
+ );
495
+ return invited.payload;
496
+ }
497
+
498
+ function normalizeRoomInviteRequest(request: RoomInviteRequest): RoomInviteRequest {
499
+ return request.username
500
+ ? { ...request, username: request.username.trim().replace(/^@+/, "") }
501
+ : request;
502
+ }
503
+
489
504
  export async function removeRoomMember(
490
505
  config: RoomClientConfig,
491
506
  request: RoomRemoveMemberRequest,
@@ -1044,6 +1044,18 @@ export async function removeSpaceMember(
1044
1044
  );
1045
1045
  }
1046
1046
 
1047
+ /** Leave a server as the authenticated caller. This is deliberately separate
1048
+ * from the manager-only member-removal route so clients never send their own
1049
+ * userId as authorization data. */
1050
+ export async function leaveSpace(
1051
+ config: RoomSocialConfig,
1052
+ spaceId: string,
1053
+ ): Promise<{ left?: boolean }> {
1054
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/membership`, {
1055
+ method: "DELETE",
1056
+ });
1057
+ }
1058
+
1047
1059
  export async function changeSpaceMemberRole(
1048
1060
  config: RoomSocialConfig,
1049
1061
  spaceId: string,