@relaya-chat/core 1.0.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/dist/apiClient.d.ts +246 -0
- package/dist/apiClient.js +241 -0
- package/dist/chatConnection.d.ts +84 -0
- package/dist/chatConnection.js +182 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/messageUtils.d.ts +106 -0
- package/dist/messageUtils.js +221 -0
- package/dist/types.d.ts +285 -0
- package/dist/types.js +17 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JAB Ventures, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API client for the Relaya chat system.
|
|
3
|
+
*
|
|
4
|
+
* Fetch-based, works in both browser and React Native environments.
|
|
5
|
+
* All methods throw an ApiError on non-2xx responses.
|
|
6
|
+
*/
|
|
7
|
+
import type { MessageWithAuthor, Ban, MessageReport, Station, Permission, Role, StickerListing } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Wave 6: AT/RT auth overhaul.
|
|
10
|
+
* POST /auth/verify-code now returns { accessToken, refreshToken, user, station }.
|
|
11
|
+
* No cookie is set; tokens are managed entirely client-side.
|
|
12
|
+
*/
|
|
13
|
+
export interface AuthVerifyResponse {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
user: {
|
|
17
|
+
id: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
avatarUrl: string | null;
|
|
20
|
+
permissions: Permission[];
|
|
21
|
+
roles: Role[];
|
|
22
|
+
};
|
|
23
|
+
station: {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface AuthRefreshResponse {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
refreshToken: string;
|
|
32
|
+
}
|
|
33
|
+
export interface MessagesResponse {
|
|
34
|
+
messages: MessageWithAuthor[];
|
|
35
|
+
hasMore: boolean;
|
|
36
|
+
/** ISO 8601 date string — messages older than this are filtered by the read layer.
|
|
37
|
+
* Determined by the space's subscription tier (retentionDays in TIER_LIMITS).
|
|
38
|
+
* The client uses this to display a "chat history before [date] is not available on this plan" notice. */
|
|
39
|
+
retentionCutoff?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ReportWithDetails {
|
|
42
|
+
reportId: string;
|
|
43
|
+
messageId: string;
|
|
44
|
+
messageContent: string | null;
|
|
45
|
+
messageIsDeleted: boolean;
|
|
46
|
+
messageAuthor: {
|
|
47
|
+
userId: string;
|
|
48
|
+
displayName: string;
|
|
49
|
+
};
|
|
50
|
+
reporter: {
|
|
51
|
+
userId: string;
|
|
52
|
+
displayName: string;
|
|
53
|
+
};
|
|
54
|
+
reason: string;
|
|
55
|
+
details: string | null;
|
|
56
|
+
status: 'pending' | 'reviewed' | 'dismissed';
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ModerationConfig {
|
|
60
|
+
rateLimitWindowMs: number;
|
|
61
|
+
rateLimitMaxMessages: number;
|
|
62
|
+
duplicateWindowMs: number;
|
|
63
|
+
}
|
|
64
|
+
export interface ModerationConfigResponse {
|
|
65
|
+
config: ModerationConfig;
|
|
66
|
+
note: string;
|
|
67
|
+
}
|
|
68
|
+
export interface PresenceConfig {
|
|
69
|
+
presenceGracePeriodMs: number;
|
|
70
|
+
}
|
|
71
|
+
export interface PresenceConfigResponse {
|
|
72
|
+
config: PresenceConfig;
|
|
73
|
+
}
|
|
74
|
+
export interface BanWithUser extends Ban {
|
|
75
|
+
bannedUser?: {
|
|
76
|
+
id: string;
|
|
77
|
+
displayName: string;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export interface MemberWithRoles {
|
|
81
|
+
id: string;
|
|
82
|
+
userId: string;
|
|
83
|
+
displayName: string;
|
|
84
|
+
email: string | null;
|
|
85
|
+
permissions: Permission[];
|
|
86
|
+
roles: Role[];
|
|
87
|
+
maxPriority: number;
|
|
88
|
+
joinedAt: string;
|
|
89
|
+
}
|
|
90
|
+
export interface ApiError {
|
|
91
|
+
status: number;
|
|
92
|
+
code: string;
|
|
93
|
+
message: string;
|
|
94
|
+
}
|
|
95
|
+
export interface ThemeByMode {
|
|
96
|
+
light: Record<string, string>;
|
|
97
|
+
dark: Record<string, string>;
|
|
98
|
+
}
|
|
99
|
+
export interface StationSoundsResponse {
|
|
100
|
+
mentionSoundUrl: string | null;
|
|
101
|
+
channelSoundUrl: string | null;
|
|
102
|
+
}
|
|
103
|
+
export interface AdminMember {
|
|
104
|
+
userId: string;
|
|
105
|
+
displayName: string;
|
|
106
|
+
email: string | null;
|
|
107
|
+
avatarUrl: string | null;
|
|
108
|
+
roles: string[];
|
|
109
|
+
joinedAt: string;
|
|
110
|
+
isOnline: boolean;
|
|
111
|
+
}
|
|
112
|
+
export interface GetMembersAdminResponse {
|
|
113
|
+
members: AdminMember[];
|
|
114
|
+
quota: {
|
|
115
|
+
used: number;
|
|
116
|
+
limit: number | null;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export declare class ApiClient {
|
|
120
|
+
private readonly baseUrl;
|
|
121
|
+
private readonly getToken;
|
|
122
|
+
/**
|
|
123
|
+
* @param baseUrl - Base URL for all requests (e.g. '' for same-origin in browser,
|
|
124
|
+
* or 'http://localhost:9000' for direct connection)
|
|
125
|
+
* @param getToken - Callback that returns the current JWT (or null if unauthenticated)
|
|
126
|
+
*/
|
|
127
|
+
constructor(baseUrl: string, getToken: () => string | null);
|
|
128
|
+
private authHeaders;
|
|
129
|
+
private request;
|
|
130
|
+
private parseResponse;
|
|
131
|
+
login(email: string, stationSlug: string): Promise<{
|
|
132
|
+
message: string;
|
|
133
|
+
expiresIn: number;
|
|
134
|
+
}>;
|
|
135
|
+
/**
|
|
136
|
+
* Request a 6-digit OTP code to be sent to the provided email
|
|
137
|
+
* Returns pending_id for use in verifyCode()
|
|
138
|
+
*/
|
|
139
|
+
requestCode(email: string, stationSlug: string): Promise<{
|
|
140
|
+
message: string;
|
|
141
|
+
pendingId: string;
|
|
142
|
+
expiresIn: number;
|
|
143
|
+
}>;
|
|
144
|
+
/**
|
|
145
|
+
* Verify OTP code and obtain session
|
|
146
|
+
*/
|
|
147
|
+
verifyCode(pendingId: string, code: string, stationSlug: string): Promise<AuthVerifyResponse>;
|
|
148
|
+
verify(token: string, stationSlug: string): Promise<AuthVerifyResponse>;
|
|
149
|
+
/**
|
|
150
|
+
* Exchange a refresh token for a new access token + rotated refresh token.
|
|
151
|
+
* Wave 6: accepts refreshToken in body (no cookie).
|
|
152
|
+
*/
|
|
153
|
+
refresh(refreshToken: string): Promise<AuthRefreshResponse>;
|
|
154
|
+
getStation(slug: string): Promise<Station>;
|
|
155
|
+
getMessages(stationSlug: string, params?: {
|
|
156
|
+
before?: string;
|
|
157
|
+
after?: string;
|
|
158
|
+
limit?: number;
|
|
159
|
+
}): Promise<MessagesResponse>;
|
|
160
|
+
deleteMessage(stationSlug: string, messageId: string): Promise<void>;
|
|
161
|
+
editMessage(stationSlug: string, messageId: string, content: string): Promise<MessageWithAuthor>;
|
|
162
|
+
createReport(stationSlug: string, messageId: string, reason: string, details?: string): Promise<MessageReport>;
|
|
163
|
+
getReports(stationSlug: string, params?: {
|
|
164
|
+
status?: string;
|
|
165
|
+
limit?: number;
|
|
166
|
+
offset?: number;
|
|
167
|
+
}): Promise<{
|
|
168
|
+
reports: ReportWithDetails[];
|
|
169
|
+
total: number;
|
|
170
|
+
}>;
|
|
171
|
+
updateReport(stationSlug: string, reportId: string, update: {
|
|
172
|
+
status: 'reviewed' | 'dismissed';
|
|
173
|
+
}): Promise<{
|
|
174
|
+
reportId: string;
|
|
175
|
+
status: string;
|
|
176
|
+
reviewedBy: string;
|
|
177
|
+
reviewedAt: string;
|
|
178
|
+
}>;
|
|
179
|
+
createBan(stationSlug: string, userId: string, params?: {
|
|
180
|
+
reason?: string;
|
|
181
|
+
expiresAt?: string;
|
|
182
|
+
}): Promise<BanWithUser>;
|
|
183
|
+
liftBan(stationSlug: string, banId: string): Promise<void>;
|
|
184
|
+
getBans(stationSlug: string, activeOnly?: boolean): Promise<{
|
|
185
|
+
bans: BanWithUser[];
|
|
186
|
+
}>;
|
|
187
|
+
getMembers(stationSlug: string): Promise<{
|
|
188
|
+
members: MemberWithRoles[];
|
|
189
|
+
}>;
|
|
190
|
+
/** Admin-only: returns member list with email addresses and moderator quota. */
|
|
191
|
+
getMembersAdmin(stationSlug: string): Promise<GetMembersAdminResponse>;
|
|
192
|
+
updateMemberRole(stationSlug: string, userId: string, roleId: string): Promise<MemberWithRoles>;
|
|
193
|
+
/** Assign or remove named roles for a member (admin-only). */
|
|
194
|
+
patchMemberRoles(stationSlug: string, userId: string, changes: {
|
|
195
|
+
add?: string[];
|
|
196
|
+
remove?: string[];
|
|
197
|
+
}): Promise<{
|
|
198
|
+
userId: string;
|
|
199
|
+
roles: string[];
|
|
200
|
+
}>;
|
|
201
|
+
getMe(stationSlug: string): Promise<{
|
|
202
|
+
userId: string;
|
|
203
|
+
displayName: string;
|
|
204
|
+
chatName: string | null;
|
|
205
|
+
permissions: Permission[];
|
|
206
|
+
roles: Role[];
|
|
207
|
+
}>;
|
|
208
|
+
updateChatName(stationSlug: string, chatName: string | null): Promise<{
|
|
209
|
+
chatName: string | null;
|
|
210
|
+
displayName: string;
|
|
211
|
+
}>;
|
|
212
|
+
getModerationConfig(stationSlug: string): Promise<ModerationConfigResponse>;
|
|
213
|
+
updateModerationConfig(stationSlug: string, updates: Partial<ModerationConfig>): Promise<ModerationConfigResponse>;
|
|
214
|
+
getPresenceConfig(stationSlug: string): Promise<PresenceConfigResponse>;
|
|
215
|
+
updatePresenceConfig(stationSlug: string, updates: Partial<PresenceConfig>): Promise<PresenceConfigResponse>;
|
|
216
|
+
getStickers(stationSlug: string): Promise<{
|
|
217
|
+
stickers: StickerListing[];
|
|
218
|
+
quota: {
|
|
219
|
+
used: number;
|
|
220
|
+
limit: number;
|
|
221
|
+
};
|
|
222
|
+
}>;
|
|
223
|
+
uploadSticker(stationSlug: string, file: Blob, filename: string): Promise<{
|
|
224
|
+
sticker: StickerListing;
|
|
225
|
+
quota: {
|
|
226
|
+
used: number;
|
|
227
|
+
limit: number;
|
|
228
|
+
};
|
|
229
|
+
}>;
|
|
230
|
+
updateStickerManifest(stationSlug: string, stickers: Array<{
|
|
231
|
+
filename: string;
|
|
232
|
+
shortcode: string | null;
|
|
233
|
+
}>): Promise<{
|
|
234
|
+
stickers: StickerListing[];
|
|
235
|
+
}>;
|
|
236
|
+
deleteSticker(stationSlug: string, filename: string): Promise<void>;
|
|
237
|
+
/** Returns the per-station audio notification URLs, or null values if none configured. */
|
|
238
|
+
getSounds(stationSlug: string): Promise<StationSoundsResponse>;
|
|
239
|
+
/** Returns the stored theme overrides for a space, or { light:{}, dark:{} } if none saved. */
|
|
240
|
+
getSpaceTheme(stationSlug: string): Promise<ThemeByMode>;
|
|
241
|
+
/**
|
|
242
|
+
* Saves theme overrides for a space (admin-only).
|
|
243
|
+
* Pass { light:{}, dark:{} } to clear all overrides.
|
|
244
|
+
*/
|
|
245
|
+
saveSpaceTheme(stationSlug: string, theme: ThemeByMode): Promise<ThemeByMode>;
|
|
246
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
2
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
3
|
+
/**
|
|
4
|
+
* REST API client for the Relaya chat system.
|
|
5
|
+
*
|
|
6
|
+
* Fetch-based, works in both browser and React Native environments.
|
|
7
|
+
* All methods throw an ApiError on non-2xx responses.
|
|
8
|
+
*/
|
|
9
|
+
// ==================== CLIENT ====================
|
|
10
|
+
export class ApiClient {
|
|
11
|
+
baseUrl;
|
|
12
|
+
getToken;
|
|
13
|
+
/**
|
|
14
|
+
* @param baseUrl - Base URL for all requests (e.g. '' for same-origin in browser,
|
|
15
|
+
* or 'http://localhost:9000' for direct connection)
|
|
16
|
+
* @param getToken - Callback that returns the current JWT (or null if unauthenticated)
|
|
17
|
+
*/
|
|
18
|
+
constructor(baseUrl, getToken) {
|
|
19
|
+
this.baseUrl = baseUrl;
|
|
20
|
+
this.getToken = getToken;
|
|
21
|
+
}
|
|
22
|
+
authHeaders() {
|
|
23
|
+
const token = this.getToken();
|
|
24
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
25
|
+
}
|
|
26
|
+
async request(method, path, body) {
|
|
27
|
+
const url = `${this.baseUrl}${path}`;
|
|
28
|
+
const headers = { ...this.authHeaders() };
|
|
29
|
+
if (body !== undefined) {
|
|
30
|
+
headers['Content-Type'] = 'application/json';
|
|
31
|
+
}
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
method,
|
|
34
|
+
headers,
|
|
35
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
36
|
+
});
|
|
37
|
+
if (res.status === 204)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
let errBody = {};
|
|
41
|
+
try {
|
|
42
|
+
errBody = (await res.json());
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// ignore parse errors
|
|
46
|
+
}
|
|
47
|
+
const errObj = errBody?.error;
|
|
48
|
+
const err = {
|
|
49
|
+
status: res.status,
|
|
50
|
+
code: (typeof errObj === 'object' ? errObj?.code : undefined) ?? 'API_ERROR',
|
|
51
|
+
message: (typeof errObj === 'object' ? errObj?.message : errObj) ?? res.statusText,
|
|
52
|
+
};
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
return res.json();
|
|
56
|
+
}
|
|
57
|
+
async parseResponse(res) {
|
|
58
|
+
if (res.status === 204)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
let errBody = {};
|
|
62
|
+
try {
|
|
63
|
+
errBody = (await res.json());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// ignore parse errors
|
|
67
|
+
}
|
|
68
|
+
const errObj = errBody?.error;
|
|
69
|
+
const err = {
|
|
70
|
+
status: res.status,
|
|
71
|
+
code: (typeof errObj === 'object' ? errObj?.code : undefined) ?? 'API_ERROR',
|
|
72
|
+
message: (typeof errObj === 'object' ? errObj?.message : errObj) ?? res.statusText,
|
|
73
|
+
};
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
return res.json();
|
|
77
|
+
}
|
|
78
|
+
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
79
|
+
async login(email, stationSlug) {
|
|
80
|
+
return this.request('POST', '/auth/login', { email, stationSlug });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Request a 6-digit OTP code to be sent to the provided email
|
|
84
|
+
* Returns pending_id for use in verifyCode()
|
|
85
|
+
*/
|
|
86
|
+
async requestCode(email, stationSlug) {
|
|
87
|
+
return this.request('POST', '/auth/request-code', { email, stationSlug });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Verify OTP code and obtain session
|
|
91
|
+
*/
|
|
92
|
+
async verifyCode(pendingId, code, stationSlug) {
|
|
93
|
+
return this.request('POST', '/auth/verify-code', { pendingId, code, stationSlug });
|
|
94
|
+
}
|
|
95
|
+
async verify(token, stationSlug) {
|
|
96
|
+
return this.request('GET', `/auth/verify?token=${encodeURIComponent(token)}&station=${encodeURIComponent(stationSlug)}`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Exchange a refresh token for a new access token + rotated refresh token.
|
|
100
|
+
* Wave 6: accepts refreshToken in body (no cookie).
|
|
101
|
+
*/
|
|
102
|
+
async refresh(refreshToken) {
|
|
103
|
+
return this.request('POST', '/auth/refresh', { refreshToken });
|
|
104
|
+
}
|
|
105
|
+
// ── Station ───────────────────────────────────────────────────────────────
|
|
106
|
+
async getStation(slug) {
|
|
107
|
+
return this.request('GET', `/api/chat/stations/${encodeURIComponent(slug)}`);
|
|
108
|
+
}
|
|
109
|
+
// ── Messages ──────────────────────────────────────────────────────────────
|
|
110
|
+
async getMessages(stationSlug, params) {
|
|
111
|
+
const qs = new URLSearchParams();
|
|
112
|
+
if (params?.before)
|
|
113
|
+
qs.set('before', params.before);
|
|
114
|
+
if (params?.after)
|
|
115
|
+
qs.set('after', params.after);
|
|
116
|
+
if (params?.limit !== undefined)
|
|
117
|
+
qs.set('limit', String(params.limit));
|
|
118
|
+
const query = qs.toString() ? `?${qs.toString()}` : '';
|
|
119
|
+
return this.request('GET', `/api/chat/${stationSlug}/messages${query}`);
|
|
120
|
+
}
|
|
121
|
+
async deleteMessage(stationSlug, messageId) {
|
|
122
|
+
return this.request('DELETE', `/api/chat/${stationSlug}/messages/${messageId}`);
|
|
123
|
+
}
|
|
124
|
+
async editMessage(stationSlug, messageId, content) {
|
|
125
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/messages/${messageId}`, { content });
|
|
126
|
+
}
|
|
127
|
+
// ── Reports ───────────────────────────────────────────────────────────────
|
|
128
|
+
async createReport(stationSlug, messageId, reason, details) {
|
|
129
|
+
return this.request('POST', `/api/chat/${stationSlug}/messages/${messageId}/report`, {
|
|
130
|
+
reason,
|
|
131
|
+
details,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async getReports(stationSlug, params) {
|
|
135
|
+
const qs = new URLSearchParams();
|
|
136
|
+
if (params?.status)
|
|
137
|
+
qs.set('status', params.status);
|
|
138
|
+
if (params?.limit !== undefined)
|
|
139
|
+
qs.set('limit', String(params.limit));
|
|
140
|
+
if (params?.offset !== undefined)
|
|
141
|
+
qs.set('offset', String(params.offset));
|
|
142
|
+
const query = qs.toString() ? `?${qs.toString()}` : '';
|
|
143
|
+
return this.request('GET', `/api/chat/${stationSlug}/reports${query}`);
|
|
144
|
+
}
|
|
145
|
+
async updateReport(stationSlug, reportId, update) {
|
|
146
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/reports/${reportId}`, update);
|
|
147
|
+
}
|
|
148
|
+
// ── Bans ──────────────────────────────────────────────────────────────────
|
|
149
|
+
async createBan(stationSlug, userId, params) {
|
|
150
|
+
return this.request('POST', `/api/chat/${stationSlug}/bans`, { userId, ...params });
|
|
151
|
+
}
|
|
152
|
+
async liftBan(stationSlug, banId) {
|
|
153
|
+
return this.request('DELETE', `/api/chat/${stationSlug}/bans/${banId}`);
|
|
154
|
+
}
|
|
155
|
+
async getBans(stationSlug, activeOnly = true) {
|
|
156
|
+
const qs = activeOnly ? '?active=true' : '';
|
|
157
|
+
return this.request('GET', `/api/chat/${stationSlug}/bans${qs}`);
|
|
158
|
+
}
|
|
159
|
+
// ── Members ───────────────────────────────────────────────────────────────
|
|
160
|
+
async getMembers(stationSlug) {
|
|
161
|
+
return this.request('GET', `/api/chat/${stationSlug}/members`);
|
|
162
|
+
}
|
|
163
|
+
/** Admin-only: returns member list with email addresses and moderator quota. */
|
|
164
|
+
async getMembersAdmin(stationSlug) {
|
|
165
|
+
return this.request('GET', `/api/chat/${stationSlug}/members/admin`);
|
|
166
|
+
}
|
|
167
|
+
async updateMemberRole(stationSlug, userId, roleId) {
|
|
168
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/members/${userId}/roles`, { roleId });
|
|
169
|
+
}
|
|
170
|
+
/** Assign or remove named roles for a member (admin-only). */
|
|
171
|
+
async patchMemberRoles(stationSlug, userId, changes) {
|
|
172
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/members/${userId}/roles`, changes);
|
|
173
|
+
}
|
|
174
|
+
// ── Profile (Me) ──────────────────────────────────────────────────────────
|
|
175
|
+
async getMe(stationSlug) {
|
|
176
|
+
return this.request('GET', `/api/chat/${stationSlug}/me`);
|
|
177
|
+
}
|
|
178
|
+
async updateChatName(stationSlug, chatName) {
|
|
179
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/me`, { chatName });
|
|
180
|
+
}
|
|
181
|
+
// ── Moderation Config ─────────────────────────────────────────────────────
|
|
182
|
+
async getModerationConfig(stationSlug) {
|
|
183
|
+
return this.request('GET', `/api/chat/${stationSlug}/moderation/config`);
|
|
184
|
+
}
|
|
185
|
+
async updateModerationConfig(stationSlug, updates) {
|
|
186
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/moderation/config`, updates);
|
|
187
|
+
}
|
|
188
|
+
// ── Presence Config ───────────────────────────────────────────────────────
|
|
189
|
+
async getPresenceConfig(stationSlug) {
|
|
190
|
+
return this.request('GET', `/api/chat/${stationSlug}/presence/config`);
|
|
191
|
+
}
|
|
192
|
+
async updatePresenceConfig(stationSlug, updates) {
|
|
193
|
+
return this.request('PATCH', `/api/chat/${stationSlug}/presence/config`, updates);
|
|
194
|
+
}
|
|
195
|
+
// ── Stickers ──────────────────────────────────────────────────────────────
|
|
196
|
+
async getStickers(stationSlug) {
|
|
197
|
+
const result = await this.request('GET', `/api/chat/${stationSlug}/stickers`);
|
|
198
|
+
// Sticker URLs from the server are relative paths (/files/stations/…/stickers/…).
|
|
199
|
+
// When the chat widget is embedded cross-origin (e.g. www on port 3000, server on port 9000),
|
|
200
|
+
// the browser would resolve them against the wrong origin. Prepend baseUrl to make them absolute.
|
|
201
|
+
if (this.baseUrl) {
|
|
202
|
+
result.stickers = result.stickers.map((s) => s.url.startsWith('/') ? { ...s, url: `${this.baseUrl}${s.url}` } : s);
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
async uploadSticker(stationSlug, file, filename) {
|
|
207
|
+
const res = await fetch(`${this.baseUrl}/api/chat/${stationSlug}/stickers/upload`, {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: {
|
|
210
|
+
...this.authHeaders(),
|
|
211
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
212
|
+
'X-Sticker-Filename': encodeURIComponent(filename),
|
|
213
|
+
},
|
|
214
|
+
body: file,
|
|
215
|
+
});
|
|
216
|
+
return this.parseResponse(res);
|
|
217
|
+
}
|
|
218
|
+
async updateStickerManifest(stationSlug, stickers) {
|
|
219
|
+
return this.request('PUT', `/api/chat/${stationSlug}/stickers/manifest`, { stickers });
|
|
220
|
+
}
|
|
221
|
+
async deleteSticker(stationSlug, filename) {
|
|
222
|
+
return this.request('DELETE', `/api/chat/${stationSlug}/stickers/${encodeURIComponent(filename)}`);
|
|
223
|
+
}
|
|
224
|
+
// ── Station Sounds ────────────────────────────────────────────────────────
|
|
225
|
+
/** Returns the per-station audio notification URLs, or null values if none configured. */
|
|
226
|
+
async getSounds(stationSlug) {
|
|
227
|
+
return this.request('GET', `/api/chat/${encodeURIComponent(stationSlug)}/sounds`);
|
|
228
|
+
}
|
|
229
|
+
// ── Space Theme ───────────────────────────────────────────────────────────
|
|
230
|
+
/** Returns the stored theme overrides for a space, or { light:{}, dark:{} } if none saved. */
|
|
231
|
+
async getSpaceTheme(stationSlug) {
|
|
232
|
+
return this.request('GET', `/api/chat/${stationSlug}/theme`);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Saves theme overrides for a space (admin-only).
|
|
236
|
+
* Pass { light:{}, dark:{} } to clear all overrides.
|
|
237
|
+
*/
|
|
238
|
+
async saveSpaceTheme(stationSlug, theme) {
|
|
239
|
+
return this.request('PUT', `/api/chat/${stationSlug}/theme`, theme);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection manager for the Relaya chat system.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Connection lifecycle (connect, disconnect, reconnect)
|
|
6
|
+
* - Exponential backoff for reconnection attempts
|
|
7
|
+
* - Application-level heartbeat pong responses
|
|
8
|
+
* - Status change callbacks for UI indicators
|
|
9
|
+
*
|
|
10
|
+
* Designed for use in both browser (native WebSocket) and React Native
|
|
11
|
+
* (via the same global WebSocket API surface in React Native).
|
|
12
|
+
*/
|
|
13
|
+
import type { WsClientMessage, WsServerMessage } from './types.js';
|
|
14
|
+
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
|
15
|
+
export interface ChatConnectionOptions {
|
|
16
|
+
/** Base delay for exponential backoff in ms (default: 1000) */
|
|
17
|
+
backoffBaseMs?: number;
|
|
18
|
+
/** Maximum backoff delay cap in ms (default: 30000) */
|
|
19
|
+
backoffMaxMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Called when the server forces the client to log out — either via a
|
|
22
|
+
* `force_logout` WS message or a WS close with code 4001. The caller
|
|
23
|
+
* should clear auth state and stop reconnecting.
|
|
24
|
+
*/
|
|
25
|
+
onAuthRevoked?: () => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Manages a single WebSocket connection to the Relaya chat server.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* const conn = new ChatConnection(
|
|
32
|
+
* () => `ws://localhost:9000/ws?token=${token}&station=balearic-fm`,
|
|
33
|
+
* (msg) => dispatch(msg),
|
|
34
|
+
* (status) => setConnectionStatus(status)
|
|
35
|
+
* );
|
|
36
|
+
* conn.connect();
|
|
37
|
+
* conn.send({ type: 'message:send', content: 'hello', clientId: '...' });
|
|
38
|
+
* conn.close(); // on component unmount
|
|
39
|
+
*/
|
|
40
|
+
export declare class ChatConnection {
|
|
41
|
+
private readonly buildWsUrl;
|
|
42
|
+
private readonly onMessage;
|
|
43
|
+
private readonly onStatusChange;
|
|
44
|
+
private ws;
|
|
45
|
+
private status;
|
|
46
|
+
private reconnectAttempt;
|
|
47
|
+
private reconnectTimer;
|
|
48
|
+
/** Set to true when `close()` is called; prevents any further reconnect. */
|
|
49
|
+
private closed;
|
|
50
|
+
private readonly backoffBaseMs;
|
|
51
|
+
private readonly backoffMaxMs;
|
|
52
|
+
private readonly onAuthRevoked;
|
|
53
|
+
/**
|
|
54
|
+
* @param buildWsUrl - Called each time a new WebSocket is opened; allows
|
|
55
|
+
* the caller to inject a fresh JWT on reconnect.
|
|
56
|
+
* @param onMessage - Receives every parsed WsServerMessage except `ping`
|
|
57
|
+
* (pings are handled internally with an auto-pong) and
|
|
58
|
+
* `force_logout` (handled internally by stopping the
|
|
59
|
+
* connection and invoking `options.onAuthRevoked`).
|
|
60
|
+
* @param onStatusChange - Called whenever the connection status changes.
|
|
61
|
+
* @param options - Backoff tuning and auth-revocation callback (optional).
|
|
62
|
+
*/
|
|
63
|
+
constructor(buildWsUrl: () => string, onMessage: (msg: WsServerMessage) => void, onStatusChange: (status: ConnectionStatus) => void, options?: ChatConnectionOptions);
|
|
64
|
+
getStatus(): ConnectionStatus;
|
|
65
|
+
/** Open the WebSocket connection (or start the reconnect cycle). */
|
|
66
|
+
connect(): void;
|
|
67
|
+
/** Send a message to the server. Silently dropped if not connected. */
|
|
68
|
+
send(msg: WsClientMessage): void;
|
|
69
|
+
/**
|
|
70
|
+
* Permanently close the connection and stop any pending reconnect.
|
|
71
|
+
* After calling this, the instance should be discarded.
|
|
72
|
+
*/
|
|
73
|
+
close(): void;
|
|
74
|
+
private openSocket;
|
|
75
|
+
private scheduleReconnect;
|
|
76
|
+
private clearReconnectTimer;
|
|
77
|
+
private setStatus;
|
|
78
|
+
/**
|
|
79
|
+
* Permanently stop this connection (no reconnect) and invoke the
|
|
80
|
+
* onAuthRevoked callback so the caller can clear auth state.
|
|
81
|
+
* Called when the server sends a force_logout message or closes with code 4001.
|
|
82
|
+
*/
|
|
83
|
+
private handleAuthRevoked;
|
|
84
|
+
}
|