@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.
- package/LICENSE +21 -0
- package/README.ko.md +99 -0
- package/README.md +99 -0
- package/dist/index.cjs +1192 -0
- package/dist/index.d.cts +675 -0
- package/dist/index.d.ts +675 -0
- package/dist/index.js +1154 -0
- package/package.json +42 -0
- package/src/chat-message.ts +209 -0
- package/src/context.tsx +72 -0
- package/src/index.ts +83 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/member-list-store.ts +116 -0
- package/src/room-files-store.ts +130 -0
- package/src/room-list-store.ts +227 -0
- package/src/room-settings-store.ts +200 -0
- package/src/room-store.ts +475 -0
- package/src/use-member-list.ts +46 -0
- package/src/use-messages.ts +62 -0
- package/src/use-room-files.ts +53 -0
- package/src/use-room-list.ts +57 -0
- package/src/use-room-settings.ts +47 -0
- package/src/use-room.ts +21 -0
- package/src/use-typing.ts +85 -0
- package/src/use-unread.ts +65 -0
|
@@ -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
|
+
}
|