@noverachat/sdk-react 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,227 @@
1
+ import type {
2
+ MemberPreview,
3
+ NoveraChat,
4
+ UnreadRoomItem,
5
+ WsChatReceive,
6
+ } from "@noverachat/sdk-web";
7
+
8
+ /**
9
+ * One row of a chat-room list screen.
10
+ *
11
+ * A live-updatable view over `UnreadRoomItem`: same fields, plus
12
+ * `lastMessageAtMs` which is filled from realtime messages as they arrive
13
+ * (the initial REST load doesn't carry a timestamp yet) and
14
+ * `anyMemberOnline` for the green presence dot.
15
+ */
16
+ export interface RoomListItem {
17
+ roomId: string;
18
+ name: string | null;
19
+ roomType: string | null;
20
+ unreadCount: number;
21
+ lastMessageId: string | null;
22
+ lastMessagePreview: string | null;
23
+ memberCount: number;
24
+ members: MemberPreview[];
25
+ /** unix ms of the last message β€” filled from realtime frames onward. */
26
+ lastMessageAtMs: number | null;
27
+ /** Green dot β€” true when any preview member is online (as of the last
28
+ * list load). */
29
+ anyMemberOnline: boolean;
30
+ }
31
+
32
+ function itemFromUnread(r: UnreadRoomItem): RoomListItem {
33
+ const members = r.membersPreview ?? [];
34
+ return {
35
+ roomId: r.roomId,
36
+ name: r.name,
37
+ roomType: r.roomType ?? null,
38
+ unreadCount: r.unreadCount,
39
+ lastMessageId: r.lastMessageId ?? null,
40
+ lastMessagePreview: r.lastMessagePreview ?? null,
41
+ memberCount: r.memberCount ?? 0,
42
+ members,
43
+ lastMessageAtMs: null,
44
+ anyMemberOnline: members.some((m) => m.isOnline === true),
45
+ };
46
+ }
47
+
48
+ export interface RoomListSnapshot {
49
+ /** Rooms, most-recently-active first. */
50
+ rooms: RoomListItem[];
51
+ /** Sum of every room's unread badge (for a bottom-tab badge). */
52
+ totalUnread: number;
53
+ }
54
+
55
+ export interface RoomListStoreOptions {
56
+ /** Incoming message β†’ list preview string. Default: `content`, or "πŸ“Ž"
57
+ * for file messages. */
58
+ previewBuilder?: (m: WsChatReceive) => string;
59
+ /** Return true while the user is looking at the given room, so its new
60
+ * messages don't bump the badge (the room screen's `markRead` owns the
61
+ * read state then). */
62
+ isRoomVisible?: (roomId: string) => boolean;
63
+ }
64
+
65
+ /**
66
+ * Chat-room list state β€” the data behind a KakaoTalk-style "μ±„νŒ…" tab.
67
+ *
68
+ * Loads the room list from `chat.unreadSummary()` (rooms come back
69
+ * most-recently-active first) and keeps it live:
70
+ * - a new message in any room bumps that room to the top, replaces its
71
+ * preview, and (unless `isRoomVisible` says the user is looking at it)
72
+ * increments its unread badge;
73
+ * - room membership events and cross-device read syncs trigger a
74
+ * debounced reload.
75
+ *
76
+ * When coming back from a room screen, call `markRead(roomId)` to clear
77
+ * the badge. Previews of the user's own sends are picked up by the reload
78
+ * on return (`load()` is idempotent-safe to call again).
79
+ */
80
+ export class RoomListStore {
81
+ private readonly chat: NoveraChat;
82
+ private readonly opts: RoomListStoreOptions;
83
+ private readonly listeners = new Set<() => void>();
84
+
85
+ private rooms: RoomListItem[] = [];
86
+ private index = new Map<string, number>(); // roomId β†’ rooms position
87
+ private roomSubs: Array<() => void> = [];
88
+ private clientSub: (() => void) | null = null;
89
+ private reloadTimer: ReturnType<typeof setTimeout> | null = null;
90
+ private snapshot: RoomListSnapshot = { rooms: [], totalUnread: 0 };
91
+ private attached = false;
92
+
93
+ constructor(chat: NoveraChat, opts: RoomListStoreOptions = {}) {
94
+ this.chat = chat;
95
+ this.opts = opts;
96
+ }
97
+
98
+ /** Subscribe to client-level room events. Idempotent. */
99
+ attach(): void {
100
+ if (this.attached) return;
101
+ this.attached = true;
102
+ // λ°© μƒμ„±Β·μ΄ˆλŒ€Β·κ°•ν‡΄ λ“± 멀버십 λ³€ν™” β†’ λͺ©λ‘ μžμ²΄κ°€ λ‹¬λΌμ§ˆ 수 μžˆμœΌλ‹ˆ reload
103
+ this.clientSub = this.chat.on("roomEvent", () => this.scheduleReload());
104
+ }
105
+
106
+ /** Unsubscribe everything (list state is kept for a later `attach`). */
107
+ dispose(): void {
108
+ if (!this.attached) return;
109
+ this.attached = false;
110
+ if (this.reloadTimer) clearTimeout(this.reloadTimer);
111
+ this.reloadTimer = null;
112
+ for (const u of this.roomSubs.splice(0)) u();
113
+ this.clientSub?.();
114
+ this.clientSub = null;
115
+ }
116
+
117
+ // ── external-store contract ─────────────────────────────────────
118
+
119
+ readonly subscribe = (fn: () => void): (() => void) => {
120
+ this.listeners.add(fn);
121
+ return () => this.listeners.delete(fn);
122
+ };
123
+
124
+ readonly getSnapshot = (): RoomListSnapshot => this.snapshot;
125
+
126
+ private emit(): void {
127
+ this.snapshot = {
128
+ rooms: [...this.rooms],
129
+ totalUnread: this.rooms.reduce((n, r) => n + r.unreadCount, 0),
130
+ };
131
+ for (const fn of this.listeners) fn();
132
+ }
133
+
134
+ // ── actions ─────────────────────────────────────────────────────
135
+
136
+ /** Reload the room list and re-hook the realtime subscriptions. */
137
+ async load(): Promise<void> {
138
+ const summary = await this.chat.unreadSummary();
139
+ if (!this.attached) return;
140
+ this.rooms = summary.rooms.map(itemFromUnread);
141
+ this.reindex();
142
+ this.resubscribe();
143
+ this.emit();
144
+ }
145
+
146
+ /** Hide a room (카톑 "숨기기") β€” optimistically remove it from the list
147
+ * and send `hide` to the server. On failure the next `load` brings it
148
+ * back; new activity may re-surface it per server policy (arrives via
149
+ * roomEvent β†’ reload). */
150
+ async hideRoom(roomId: string): Promise<void> {
151
+ const i = this.index.get(roomId);
152
+ if (i !== undefined) {
153
+ this.rooms.splice(i, 1);
154
+ this.reindex();
155
+ this.emit();
156
+ }
157
+ await this.chat.room(roomId).hide();
158
+ }
159
+
160
+ /** Undo `hideRoom` β€” send `unhide` and reload the list. */
161
+ async unhideRoom(roomId: string): Promise<void> {
162
+ await this.chat.room(roomId).unhide();
163
+ await this.load();
164
+ }
165
+
166
+ /** Mark a room read β€” drop its badge to 0 and advance the client's
167
+ * last-seen watermark. */
168
+ markRead(roomId: string): void {
169
+ const i = this.index.get(roomId);
170
+ if (i === undefined) return;
171
+ const item = this.rooms[i]!;
172
+ if (item.lastMessageId) this.chat.setLastSeen(roomId, item.lastMessageId);
173
+ if (item.unreadCount !== 0) {
174
+ this.rooms[i] = { ...item, unreadCount: 0 };
175
+ this.emit();
176
+ }
177
+ }
178
+
179
+ // ── internals ───────────────────────────────────────────────────
180
+
181
+ private reindex(): void {
182
+ this.index = new Map(this.rooms.map((r, i) => [r.roomId, i]));
183
+ }
184
+
185
+ private resubscribe(): void {
186
+ for (const u of this.roomSubs.splice(0)) u();
187
+ for (const item of this.rooms) {
188
+ const room = this.chat.room(item.roomId);
189
+ this.roomSubs.push(
190
+ room.on("message", (m) => this.onIncoming(item.roomId, m)),
191
+ room.on("syncTick", () => this.scheduleReload()),
192
+ room.on("messagesCleared", () => this.scheduleReload()),
193
+ );
194
+ }
195
+ }
196
+
197
+ private onIncoming(roomId: string, m: WsChatReceive): void {
198
+ const i = this.index.get(roomId);
199
+ if (i === undefined) return;
200
+ const cur = this.rooms[i]!;
201
+ const visible = this.opts.isRoomVisible?.(roomId) ?? false;
202
+ const preview =
203
+ this.opts.previewBuilder?.(m) ??
204
+ m.content ??
205
+ (m.file_id || m.file || (m.files && m.files.length > 0) ? "πŸ“Ž" : "");
206
+ const updated: RoomListItem = {
207
+ ...cur,
208
+ unreadCount: visible ? cur.unreadCount : cur.unreadCount + 1,
209
+ lastMessageId: m.message_id,
210
+ lastMessagePreview: preview,
211
+ lastMessageAtMs: m.created_at,
212
+ };
213
+ // Bump the active room to the top.
214
+ this.rooms.splice(i, 1);
215
+ this.rooms.unshift(updated);
216
+ this.reindex();
217
+ this.emit();
218
+ }
219
+
220
+ private scheduleReload(): void {
221
+ if (this.reloadTimer) clearTimeout(this.reloadTimer);
222
+ this.reloadTimer = setTimeout(() => {
223
+ this.reloadTimer = null;
224
+ if (this.attached) void this.load();
225
+ }, 500);
226
+ }
227
+ }
@@ -0,0 +1,200 @@
1
+ import type {
2
+ InviteToken,
3
+ JoinRequestOut,
4
+ NoveraChat,
5
+ PushTrigger,
6
+ Room,
7
+ } from "@noverachat/sdk-web";
8
+
9
+ export interface RoomSettingsSnapshot {
10
+ /** Invite tokens minted for this room (operator view), newest first.
11
+ * Empty until `loadInvites`. */
12
+ invites: InviteToken[];
13
+ /** Pending join requests (operator inbox). Empty until
14
+ * `loadJoinRequests`. */
15
+ joinRequests: JoinRequestOut[];
16
+ }
17
+
18
+ /**
19
+ * Actions and state behind a room-settings screen (λŒ€ν™”λ°© μ„€μ •): per-room
20
+ * mute, invite links, join-request inbox, leave / clear-history, and
21
+ * operator moderation.
22
+ *
23
+ * Mostly a thin, typed facade over `Room`'s one-shot REST calls β€” the only
24
+ * held state is the invite / join-request lists. Read the caller's
25
+ * *current* push trigger from their own row in `MemberListStore` (the
26
+ * server has no standalone "get my settings" endpoint).
27
+ */
28
+ export class RoomSettingsStore {
29
+ private readonly chat: NoveraChat;
30
+ readonly roomId: string;
31
+ private readonly listeners = new Set<() => void>();
32
+
33
+ private invites: InviteToken[] = [];
34
+ private joinRequests: JoinRequestOut[] = [];
35
+ private attached = false;
36
+ private snapshot: RoomSettingsSnapshot = { invites: [], joinRequests: [] };
37
+
38
+ constructor(chat: NoveraChat, roomId: string) {
39
+ this.chat = chat;
40
+ this.roomId = roomId;
41
+ }
42
+
43
+ private get room(): Room {
44
+ return this.chat.room(this.roomId);
45
+ }
46
+
47
+ /** No realtime subscriptions β€” kept for lifecycle symmetry with the
48
+ * other stores (guards in-flight results after unmount). */
49
+ attach(): void {
50
+ this.attached = true;
51
+ }
52
+
53
+ dispose(): void {
54
+ this.attached = false;
55
+ }
56
+
57
+ // ── external-store contract ─────────────────────────────────────
58
+
59
+ readonly subscribe = (fn: () => void): (() => void) => {
60
+ this.listeners.add(fn);
61
+ return () => this.listeners.delete(fn);
62
+ };
63
+
64
+ readonly getSnapshot = (): RoomSettingsSnapshot => this.snapshot;
65
+
66
+ private emit(): void {
67
+ this.snapshot = {
68
+ invites: [...this.invites],
69
+ joinRequests: [...this.joinRequests],
70
+ };
71
+ for (const fn of this.listeners) fn();
72
+ }
73
+
74
+ // ── notifications ───────────────────────────────────────────────
75
+
76
+ /** Convenience mute toggle: `true` β†’ `OFF`, `false` β†’ `ALL`. */
77
+ setMuted(muted: boolean): Promise<void> {
78
+ return this.room.setPushTrigger(muted ? "OFF" : "ALL");
79
+ }
80
+
81
+ /** Fine-grained per-room push trigger: `ALL` | `MENTION_ONLY` | `OFF`. */
82
+ setPushTrigger(trigger: PushTrigger): Promise<void> {
83
+ return this.room.setPushTrigger(trigger);
84
+ }
85
+
86
+ // ── invite links ────────────────────────────────────────────────
87
+
88
+ /** Fetch the invite list (operator-only server-side). */
89
+ async loadInvites(): Promise<void> {
90
+ const list = await this.room.listInvites();
91
+ if (!this.attached) return;
92
+ this.invites = list;
93
+ this.emit();
94
+ }
95
+
96
+ /** Mint a new invite link and prepend it to `invites`. */
97
+ async createInvite(opts?: {
98
+ expiresInHours?: number | null;
99
+ maxUses?: number | null;
100
+ }): Promise<InviteToken> {
101
+ const token = await this.room.createInvite(opts);
102
+ if (this.attached) {
103
+ this.invites = [token, ...this.invites];
104
+ this.emit();
105
+ }
106
+ return token;
107
+ }
108
+
109
+ /** Revoke a token and drop it from `invites`. */
110
+ async revokeInvite(token: string): Promise<void> {
111
+ await this.room.revokeInvite(token);
112
+ if (this.attached) {
113
+ this.invites = this.invites.filter((t) => t.token !== token);
114
+ this.emit();
115
+ }
116
+ }
117
+
118
+ // ── join requests (operator inbox) ──────────────────────────────
119
+
120
+ /** Fetch pending join requests (operator-only server-side). */
121
+ async loadJoinRequests(): Promise<void> {
122
+ const page = await this.room.listJoinRequests();
123
+ if (!this.attached) return;
124
+ this.joinRequests = page.items;
125
+ this.emit();
126
+ }
127
+
128
+ /** Approve a pending request and drop it from `joinRequests`. */
129
+ async approveJoinRequest(userId: string, reason?: string): Promise<void> {
130
+ await this.room.approveJoinRequest(userId, reason);
131
+ if (this.attached) {
132
+ this.joinRequests = this.joinRequests.filter((r) => r.userId !== userId);
133
+ this.emit();
134
+ }
135
+ }
136
+
137
+ /** Reject a pending request and drop it from `joinRequests`. */
138
+ async rejectJoinRequest(userId: string, reason?: string): Promise<void> {
139
+ await this.room.rejectJoinRequest(userId, reason);
140
+ if (this.attached) {
141
+ this.joinRequests = this.joinRequests.filter((r) => r.userId !== userId);
142
+ this.emit();
143
+ }
144
+ }
145
+
146
+ // ── history / membership ────────────────────────────────────────
147
+
148
+ /** Per-user "clear chat" β€” hides existing messages for the caller only. */
149
+ clearHistory(): Promise<void> {
150
+ return this.room.clearHistory();
151
+ }
152
+
153
+ /** Leave the room. `deleteMessages`: `"none"` (default) | `"mine"` |
154
+ * `"all"` β€” see `Room.leave`. */
155
+ leave(options?: { deleteMessages?: "none" | "mine" | "all" }): Promise<void> {
156
+ return this.room.leave(options);
157
+ }
158
+
159
+ /** Delete every message the caller sent (without leaving). Returns the
160
+ * cleared count. */
161
+ async deleteMyMessages(): Promise<number> {
162
+ const r = await this.room.deleteMyMessages();
163
+ return r.cleared;
164
+ }
165
+
166
+ // ── operator moderation ─────────────────────────────────────────
167
+
168
+ /** **Destructive, operator-only** β€” wipe every message in the room. */
169
+ deleteAllMessages(): Promise<{
170
+ cleared: number;
171
+ upToMessageId: string | null;
172
+ }> {
173
+ return this.room.deleteAllMessages();
174
+ }
175
+
176
+ /** Operator: block non-operator sends (`ROOM_FROZEN` server-side). */
177
+ freeze(): Promise<void> {
178
+ return this.room.freeze();
179
+ }
180
+
181
+ /** Operator: reopen the room to sends. */
182
+ unfreeze(): Promise<void> {
183
+ return this.room.unfreeze();
184
+ }
185
+
186
+ /** Operator: toggle open-chat visibility. */
187
+ setPublic(isPublic: boolean): Promise<void> {
188
+ return this.room.setPublic(isPublic);
189
+ }
190
+
191
+ /** Operator: mute a member (`durationMinutes` null β†’ indefinite). */
192
+ muteMember(userId: string, durationMinutes: number | null): Promise<void> {
193
+ return this.room.muteMember(userId, durationMinutes);
194
+ }
195
+
196
+ /** Operator: clear a member's mute. */
197
+ unmuteMember(userId: string): Promise<void> {
198
+ return this.room.unmuteMember(userId);
199
+ }
200
+ }