@relaya-chat/react-native 1.1.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.md +7 -0
- package/dist/hooks/useRelayaAuth.d.ts +57 -0
- package/dist/hooks/useRelayaAuth.d.ts.map +1 -0
- package/dist/hooks/useRelayaAuth.js +193 -0
- package/dist/hooks/useRelayaChat.d.ts +69 -0
- package/dist/hooks/useRelayaChat.d.ts.map +1 -0
- package/dist/hooks/useRelayaChat.js +365 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/utils/buildRnWsUrl.d.ts +11 -0
- package/dist/utils/buildRnWsUrl.d.ts.map +1 -0
- package/dist/utils/buildRnWsUrl.js +20 -0
- package/dist/utils/getMessageMenuItems.d.ts +29 -0
- package/dist/utils/getMessageMenuItems.d.ts.map +1 -0
- package/dist/utils/getMessageMenuItems.js +43 -0
- package/package.json +48 -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.
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @relaya-chat/react-native
|
|
2
|
+
|
|
3
|
+
> ⚠️ **No developer attention has been paid to this package yet.**
|
|
4
|
+
>
|
|
5
|
+
> This portion of the repository is planned but has not been implemented, tested, or validated in any way. There are **no assurances that anything here will work**. Do not attempt to use this package in a production or development context until this notice is removed.
|
|
6
|
+
|
|
7
|
+
Work on this package is coming soon.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRelayaAuth — AsyncStorage-based authentication for React Native / Expo.
|
|
3
|
+
*
|
|
4
|
+
* Manages the full OTP authentication lifecycle:
|
|
5
|
+
* - On mount: read token from AsyncStorage → GET /api/chat/:slug/me → set state
|
|
6
|
+
* - requestCode(email) → POST /auth/request-code → returns pendingId
|
|
7
|
+
* - verifyCode(pendingId, code) → POST /auth/verify-code → store JWT → authenticated
|
|
8
|
+
* - logout() → POST /auth/logout → delete AsyncStorage entry → anonymous
|
|
9
|
+
* - refresh() → POST /auth/refresh → replace stored token (call on app foreground)
|
|
10
|
+
*
|
|
11
|
+
* Uses Bearer token auth (not cookies). The host app installs
|
|
12
|
+
* @react-native-async-storage/async-storage as a peer dependency.
|
|
13
|
+
*/
|
|
14
|
+
import type { Permission, Role } from '@relaya-chat/core';
|
|
15
|
+
export type AuthStatus = 'loading' | 'anonymous' | 'otp-sent' | 'authenticated';
|
|
16
|
+
export interface RelayaAuthUser {
|
|
17
|
+
id: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
avatarUrl: string | null;
|
|
20
|
+
permissions: Permission[];
|
|
21
|
+
roles: Role[];
|
|
22
|
+
}
|
|
23
|
+
export interface RelayaAuthStation {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
}
|
|
28
|
+
export interface RelayaAuthState {
|
|
29
|
+
status: AuthStatus;
|
|
30
|
+
user: RelayaAuthUser | null;
|
|
31
|
+
station: RelayaAuthStation | null;
|
|
32
|
+
error: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface RelayaAuthOptions {
|
|
35
|
+
/** Relaya SaaS endpoint — always 'https://api.relaya.chat' */
|
|
36
|
+
serverUrl: string;
|
|
37
|
+
/** Your space slug, assigned by Relaya — e.g. 'balearic-fm' */
|
|
38
|
+
stationSlug: string;
|
|
39
|
+
/** AsyncStorage key for the JWT (default: 'relaya_token') */
|
|
40
|
+
tokenStorageKey?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface RelayaAuthActions {
|
|
43
|
+
/** Send a 6-digit OTP to the given email. Returns pendingId for verifyCode(). */
|
|
44
|
+
requestCode: (email: string) => Promise<{
|
|
45
|
+
pendingId: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Verify the OTP code. On success, stores JWT and transitions to 'authenticated'. */
|
|
48
|
+
verifyCode: (pendingId: string, code: string) => Promise<void>;
|
|
49
|
+
/** Log out: call server endpoint, delete stored token, transition to 'anonymous'. */
|
|
50
|
+
logout: () => Promise<void>;
|
|
51
|
+
/** Refresh the stored JWT. Call when app returns to foreground. */
|
|
52
|
+
refresh: () => Promise<void>;
|
|
53
|
+
/** Returns the current JWT synchronously (from ref). Used by ApiClient and ChatConnection. */
|
|
54
|
+
getToken: () => string | null;
|
|
55
|
+
}
|
|
56
|
+
export declare function useRelayaAuth(options: RelayaAuthOptions): RelayaAuthState & RelayaAuthActions;
|
|
57
|
+
//# sourceMappingURL=useRelayaAuth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRelayaAuth.d.ts","sourceRoot":"","sources":["../../src/hooks/useRelayaAuth.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAOH,OAAO,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAI1D,MAAM,MAAM,UAAU,GAClB,SAAS,GACT,WAAW,GACX,UAAU,GACV,eAAe,CAAC;AAEpB,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,KAAK,EAAE,IAAI,EAAE,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAClC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,iFAAiF;IACjF,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/D,sFAAsF;IACtF,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,qFAAqF;IACrF,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,mEAAmE;IACnE,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,8FAA8F;IAC9F,QAAQ,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CAC/B;AAID,wBAAgB,aAAa,CAC3B,OAAO,EAAE,iBAAiB,GACzB,eAAe,GAAG,iBAAiB,CAuMrC"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
3
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
4
|
+
/**
|
|
5
|
+
* useRelayaAuth — AsyncStorage-based authentication for React Native / Expo.
|
|
6
|
+
*
|
|
7
|
+
* Manages the full OTP authentication lifecycle:
|
|
8
|
+
* - On mount: read token from AsyncStorage → GET /api/chat/:slug/me → set state
|
|
9
|
+
* - requestCode(email) → POST /auth/request-code → returns pendingId
|
|
10
|
+
* - verifyCode(pendingId, code) → POST /auth/verify-code → store JWT → authenticated
|
|
11
|
+
* - logout() → POST /auth/logout → delete AsyncStorage entry → anonymous
|
|
12
|
+
* - refresh() → POST /auth/refresh → replace stored token (call on app foreground)
|
|
13
|
+
*
|
|
14
|
+
* Uses Bearer token auth (not cookies). The host app installs
|
|
15
|
+
* @react-native-async-storage/async-storage as a peer dependency.
|
|
16
|
+
*/
|
|
17
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
18
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.useRelayaAuth = useRelayaAuth;
|
|
22
|
+
const react_1 = require("react");
|
|
23
|
+
const react_native_1 = require("react-native");
|
|
24
|
+
const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
|
|
25
|
+
const core_1 = require("@relaya-chat/core");
|
|
26
|
+
// ── Hook ──────────────────────────────────────────────────────────────────────
|
|
27
|
+
function useRelayaAuth(options) {
|
|
28
|
+
const { serverUrl, stationSlug, tokenStorageKey = 'relaya_token' } = options;
|
|
29
|
+
const [state, setState] = (0, react_1.useState)({
|
|
30
|
+
status: 'loading',
|
|
31
|
+
user: null,
|
|
32
|
+
station: null,
|
|
33
|
+
error: null,
|
|
34
|
+
});
|
|
35
|
+
// Token ref — always holds the latest JWT without triggering re-renders.
|
|
36
|
+
// getToken() reads from here so callers always get the freshest value.
|
|
37
|
+
const tokenRef = (0, react_1.useRef)(null);
|
|
38
|
+
const getToken = (0, react_1.useCallback)(() => tokenRef.current, []);
|
|
39
|
+
// Shared ApiClient — uses getToken callback so it always sends the latest JWT.
|
|
40
|
+
const api = (0, react_1.useRef)(new core_1.ApiClient(serverUrl, getToken)).current;
|
|
41
|
+
// Guard against React StrictMode double-invocation
|
|
42
|
+
const initStartedRef = (0, react_1.useRef)(false);
|
|
43
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
44
|
+
const storeToken = (0, react_1.useCallback)(async (token) => {
|
|
45
|
+
tokenRef.current = token;
|
|
46
|
+
await async_storage_1.default.setItem(tokenStorageKey, token);
|
|
47
|
+
}, [tokenStorageKey]);
|
|
48
|
+
const clearToken = (0, react_1.useCallback)(async () => {
|
|
49
|
+
tokenRef.current = null;
|
|
50
|
+
await async_storage_1.default.removeItem(tokenStorageKey);
|
|
51
|
+
}, [tokenStorageKey]);
|
|
52
|
+
// ── Initial auth check ─────────────────────────────────────────────────────
|
|
53
|
+
(0, react_1.useEffect)(() => {
|
|
54
|
+
if (initStartedRef.current)
|
|
55
|
+
return;
|
|
56
|
+
initStartedRef.current = true;
|
|
57
|
+
async function initialize() {
|
|
58
|
+
// 1. Read stored token from AsyncStorage
|
|
59
|
+
const storedToken = await async_storage_1.default.getItem(tokenStorageKey);
|
|
60
|
+
if (!storedToken) {
|
|
61
|
+
setState((s) => ({ ...s, status: 'anonymous' }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// 2. Validate token by calling GET /me
|
|
65
|
+
tokenRef.current = storedToken;
|
|
66
|
+
try {
|
|
67
|
+
const meData = await api.getMe(stationSlug);
|
|
68
|
+
const stationData = await api.getStation(stationSlug);
|
|
69
|
+
setState({
|
|
70
|
+
status: 'authenticated',
|
|
71
|
+
user: {
|
|
72
|
+
id: meData.userId,
|
|
73
|
+
displayName: meData.displayName,
|
|
74
|
+
avatarUrl: null,
|
|
75
|
+
permissions: meData.permissions,
|
|
76
|
+
roles: meData.roles,
|
|
77
|
+
},
|
|
78
|
+
station: {
|
|
79
|
+
id: stationData.id,
|
|
80
|
+
name: stationData.name,
|
|
81
|
+
slug: stationData.slug,
|
|
82
|
+
},
|
|
83
|
+
error: null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Token invalid or expired — clear it and go anonymous
|
|
88
|
+
tokenRef.current = null;
|
|
89
|
+
await async_storage_1.default.removeItem(tokenStorageKey);
|
|
90
|
+
setState({ status: 'anonymous', user: null, station: null, error: null });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
initialize().catch(() => {
|
|
94
|
+
setState({ status: 'anonymous', user: null, station: null, error: null });
|
|
95
|
+
});
|
|
96
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
97
|
+
// ── App foreground token refresh ───────────────────────────────────────────
|
|
98
|
+
const refresh = (0, react_1.useCallback)(async () => {
|
|
99
|
+
const currentToken = tokenRef.current;
|
|
100
|
+
if (!currentToken)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
const data = await api.refresh(currentToken);
|
|
104
|
+
await storeToken(data.accessToken);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Refresh failed — token may be expired; transition to anonymous
|
|
108
|
+
await clearToken();
|
|
109
|
+
setState({ status: 'anonymous', user: null, station: null, error: null });
|
|
110
|
+
}
|
|
111
|
+
}, [api, storeToken, clearToken]);
|
|
112
|
+
(0, react_1.useEffect)(() => {
|
|
113
|
+
const subscription = react_native_1.AppState.addEventListener('change', (nextState) => {
|
|
114
|
+
if (nextState === 'active' && tokenRef.current) {
|
|
115
|
+
refresh().catch(() => {
|
|
116
|
+
// Handled inside refresh()
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return () => {
|
|
121
|
+
subscription.remove();
|
|
122
|
+
};
|
|
123
|
+
}, [refresh]);
|
|
124
|
+
// ── requestCode ────────────────────────────────────────────────────────────
|
|
125
|
+
const requestCode = (0, react_1.useCallback)(async (email) => {
|
|
126
|
+
setState((s) => ({ ...s, error: null }));
|
|
127
|
+
try {
|
|
128
|
+
const data = await api.requestCode(email, stationSlug);
|
|
129
|
+
setState((s) => ({ ...s, status: 'otp-sent' }));
|
|
130
|
+
return { pendingId: data.pendingId };
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const message = err?.message ??
|
|
134
|
+
'Failed to send verification code. Please try again.';
|
|
135
|
+
setState((s) => ({ ...s, error: message }));
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}, [api, stationSlug]);
|
|
139
|
+
// ── verifyCode ─────────────────────────────────────────────────────────────
|
|
140
|
+
const verifyCode = (0, react_1.useCallback)(async (pendingId, code) => {
|
|
141
|
+
setState((s) => ({ ...s, error: null }));
|
|
142
|
+
try {
|
|
143
|
+
const data = await api.verifyCode(pendingId, code, stationSlug);
|
|
144
|
+
await storeToken(data.accessToken);
|
|
145
|
+
setState({
|
|
146
|
+
status: 'authenticated',
|
|
147
|
+
user: {
|
|
148
|
+
id: data.user.id,
|
|
149
|
+
displayName: data.user.displayName,
|
|
150
|
+
avatarUrl: data.user.avatarUrl,
|
|
151
|
+
permissions: data.user.permissions,
|
|
152
|
+
roles: data.user.roles,
|
|
153
|
+
},
|
|
154
|
+
station: {
|
|
155
|
+
id: data.station.id,
|
|
156
|
+
name: data.station.name,
|
|
157
|
+
slug: data.station.slug,
|
|
158
|
+
},
|
|
159
|
+
error: null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
const message = err?.message ??
|
|
164
|
+
'Invalid or expired code. Please try again.';
|
|
165
|
+
setState((s) => ({ ...s, error: message }));
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}, [api, stationSlug, storeToken]);
|
|
169
|
+
// ── logout ─────────────────────────────────────────────────────────────────
|
|
170
|
+
const logout = (0, react_1.useCallback)(async () => {
|
|
171
|
+
try {
|
|
172
|
+
await fetch(`${serverUrl}/auth/logout`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: tokenRef.current
|
|
175
|
+
? { Authorization: `Bearer ${tokenRef.current}` }
|
|
176
|
+
: {},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Ignore server errors — proceed with local logout regardless
|
|
181
|
+
}
|
|
182
|
+
await clearToken();
|
|
183
|
+
setState({ status: 'anonymous', user: null, station: null, error: null });
|
|
184
|
+
}, [serverUrl, clearToken]);
|
|
185
|
+
return {
|
|
186
|
+
...state,
|
|
187
|
+
requestCode,
|
|
188
|
+
verifyCode,
|
|
189
|
+
logout,
|
|
190
|
+
refresh,
|
|
191
|
+
getToken,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRelayaChat — WebSocket + REST chat state for React Native / Expo.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the web useRelayaChat hook but:
|
|
5
|
+
* - Accepts a getToken callback instead of using cookies
|
|
6
|
+
* - Builds the WS URL from serverUrl + stationSlug props (no config.ts)
|
|
7
|
+
* - Has no NotificationMuteContext dependency (host app manages audio)
|
|
8
|
+
* - Has no sticker system (mobile UI handles stickers independently)
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Open and manage a ChatConnection WebSocket instance
|
|
12
|
+
* - Load initial messages via REST on connect (auth:success)
|
|
13
|
+
* - Catch up missed messages via REST on reconnect (cursor-based)
|
|
14
|
+
* - Load older messages on demand (scroll-up / load more)
|
|
15
|
+
* - Handle optimistic message sending with clientId reconciliation
|
|
16
|
+
* - Expose online users list from presence:update messages
|
|
17
|
+
* - Dispatch moderator REST actions (delete, ban)
|
|
18
|
+
* - Dispatch report REST action
|
|
19
|
+
*/
|
|
20
|
+
import type { Message, UserInfo, OptimisticMessage } from '@relaya-chat/core';
|
|
21
|
+
import type { ConnectionStatus } from '@relaya-chat/core';
|
|
22
|
+
import type { RelayaAuthState, RelayaAuthActions } from './useRelayaAuth';
|
|
23
|
+
export interface OnlineUser {
|
|
24
|
+
id: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
avatarUrl: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface RelayaChatOptions {
|
|
29
|
+
/** Relaya SaaS endpoint — always 'https://api.relaya.chat' */
|
|
30
|
+
serverUrl: string;
|
|
31
|
+
/** Your space slug — e.g. 'balearic-fm' */
|
|
32
|
+
stationSlug: string;
|
|
33
|
+
/** Auth state from useRelayaAuth */
|
|
34
|
+
authState: RelayaAuthState;
|
|
35
|
+
/** Token getter from useRelayaAuth — called on every WS connect and API request */
|
|
36
|
+
getToken: RelayaAuthActions['getToken'];
|
|
37
|
+
}
|
|
38
|
+
export interface RelayaChatState {
|
|
39
|
+
messages: Message[];
|
|
40
|
+
optimistic: OptimisticMessage[];
|
|
41
|
+
users: OnlineUser[];
|
|
42
|
+
userCount: number;
|
|
43
|
+
totalCount: number;
|
|
44
|
+
connectionStatus: ConnectionStatus;
|
|
45
|
+
loadingInitial: boolean;
|
|
46
|
+
loadingOlder: boolean;
|
|
47
|
+
hasOlderMessages: boolean;
|
|
48
|
+
error: string | null;
|
|
49
|
+
}
|
|
50
|
+
export interface ReplyData {
|
|
51
|
+
messageId: string;
|
|
52
|
+
authorName: string;
|
|
53
|
+
excerpt: string;
|
|
54
|
+
}
|
|
55
|
+
export interface RelayaChatActions {
|
|
56
|
+
sendMessage: (content: string, replyTo?: ReplyData) => void;
|
|
57
|
+
loadOlderMessages: () => Promise<void>;
|
|
58
|
+
editMessage: (messageId: string, newContent: string) => Promise<void>;
|
|
59
|
+
deleteMessage: (messageId: string) => Promise<void>;
|
|
60
|
+
banUser: (userId: string, params?: {
|
|
61
|
+
reason?: string;
|
|
62
|
+
expiresAt?: string;
|
|
63
|
+
}) => Promise<void>;
|
|
64
|
+
reportMessage: (messageId: string, reason: string, details?: string) => Promise<void>;
|
|
65
|
+
getUserInfo: (userId: string) => UserInfo | undefined;
|
|
66
|
+
getAvatarForMessage: (userId: string, messageTime: Date) => string | null;
|
|
67
|
+
}
|
|
68
|
+
export declare function useRelayaChat(options: RelayaChatOptions): RelayaChatState & RelayaChatActions;
|
|
69
|
+
//# sourceMappingURL=useRelayaChat.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRelayaChat.d.ts","sourceRoot":"","sources":["../../src/hooks/useRelayaChat.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;GAkBG;AAWH,OAAO,KAAK,EACV,OAAO,EACP,QAAQ,EAGR,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAW1E,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,oCAAoC;IACpC,SAAS,EAAE,eAAe,CAAC;IAC3B,mFAAmF;IACnF,QAAQ,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,UAAU,EAAE,iBAAiB,EAAE,CAAC;IAChC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IAC5D,iBAAiB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7F,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtF,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,QAAQ,GAAG,SAAS,CAAC;IACtD,mBAAmB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,KAAK,MAAM,GAAG,IAAI,CAAC;CAC3E;AAID,wBAAgB,aAAa,CAAC,OAAO,EAAE,iBAAiB,GAAG,eAAe,GAAG,iBAAiB,CAua7F"}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
3
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
4
|
+
/**
|
|
5
|
+
* useRelayaChat — WebSocket + REST chat state for React Native / Expo.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the web useRelayaChat hook but:
|
|
8
|
+
* - Accepts a getToken callback instead of using cookies
|
|
9
|
+
* - Builds the WS URL from serverUrl + stationSlug props (no config.ts)
|
|
10
|
+
* - Has no NotificationMuteContext dependency (host app manages audio)
|
|
11
|
+
* - Has no sticker system (mobile UI handles stickers independently)
|
|
12
|
+
*
|
|
13
|
+
* Responsibilities:
|
|
14
|
+
* - Open and manage a ChatConnection WebSocket instance
|
|
15
|
+
* - Load initial messages via REST on connect (auth:success)
|
|
16
|
+
* - Catch up missed messages via REST on reconnect (cursor-based)
|
|
17
|
+
* - Load older messages on demand (scroll-up / load more)
|
|
18
|
+
* - Handle optimistic message sending with clientId reconciliation
|
|
19
|
+
* - Expose online users list from presence:update messages
|
|
20
|
+
* - Dispatch moderator REST actions (delete, ban)
|
|
21
|
+
* - Dispatch report REST action
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.useRelayaChat = useRelayaChat;
|
|
25
|
+
const react_1 = require("react");
|
|
26
|
+
const core_1 = require("@relaya-chat/core");
|
|
27
|
+
const buildRnWsUrl_1 = require("../utils/buildRnWsUrl");
|
|
28
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
29
|
+
const PAGE_SIZE = 50;
|
|
30
|
+
const MAX_MESSAGES = 150;
|
|
31
|
+
const MAX_AVATAR_HISTORY = 20;
|
|
32
|
+
// ── Hook ──────────────────────────────────────────────────────────────────────
|
|
33
|
+
function useRelayaChat(options) {
|
|
34
|
+
const { serverUrl, stationSlug, authState, getToken } = options;
|
|
35
|
+
const [state, setState] = (0, react_1.useState)({
|
|
36
|
+
messages: [],
|
|
37
|
+
optimistic: [],
|
|
38
|
+
users: [],
|
|
39
|
+
userCount: 0,
|
|
40
|
+
totalCount: 0,
|
|
41
|
+
connectionStatus: 'disconnected',
|
|
42
|
+
loadingInitial: false,
|
|
43
|
+
loadingOlder: false,
|
|
44
|
+
hasOlderMessages: false,
|
|
45
|
+
error: null,
|
|
46
|
+
});
|
|
47
|
+
const connRef = (0, react_1.useRef)(null);
|
|
48
|
+
const oldestMessageIdRef = (0, react_1.useRef)(undefined);
|
|
49
|
+
const newestMessageIdRef = (0, react_1.useRef)(undefined);
|
|
50
|
+
const pendingClientIds = (0, react_1.useRef)(new Map());
|
|
51
|
+
// User directory for resolving message authors
|
|
52
|
+
const userDirectory = (0, react_1.useRef)(new Map());
|
|
53
|
+
// Avatar history for temporal tracking (session-only)
|
|
54
|
+
const avatarHistory = (0, react_1.useRef)(new Map());
|
|
55
|
+
const api = (0, react_1.useRef)(new core_1.ApiClient(serverUrl, getToken)).current;
|
|
56
|
+
// Stable refs for callbacks — prevents stale closures in long-lived WS handlers
|
|
57
|
+
const handleWsMessageRef = (0, react_1.useRef)(null);
|
|
58
|
+
const handleStatusChangeRef = (0, react_1.useRef)(null);
|
|
59
|
+
const userId = authState.user?.id ?? '';
|
|
60
|
+
// ── Temporal avatar resolution ─────────────────────────────────────────────
|
|
61
|
+
const getAvatarForMessage = (0, react_1.useCallback)((userId, messageTime) => {
|
|
62
|
+
const history = avatarHistory.current.get(userId) || [];
|
|
63
|
+
// Find most recent avatar that existed at messageTime
|
|
64
|
+
const historicalEntry = history
|
|
65
|
+
.filter((entry) => entry.changedAt <= messageTime)
|
|
66
|
+
.pop();
|
|
67
|
+
if (historicalEntry) {
|
|
68
|
+
return historicalEntry.url;
|
|
69
|
+
}
|
|
70
|
+
return userDirectory.current.get(userId)?.avatarUrl ?? null;
|
|
71
|
+
}, []);
|
|
72
|
+
// ── Load messages via REST ─────────────────────────────────────────────────
|
|
73
|
+
const loadMessages = (0, react_1.useCallback)(async (opts = {}) => {
|
|
74
|
+
if (!stationSlug)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
const res = await api.getMessages(stationSlug, {
|
|
78
|
+
after: opts.after,
|
|
79
|
+
limit: PAGE_SIZE,
|
|
80
|
+
});
|
|
81
|
+
const msgs = res.messages ?? [];
|
|
82
|
+
setState((prev) => {
|
|
83
|
+
let merged;
|
|
84
|
+
if (opts.after) {
|
|
85
|
+
merged = (0, core_1.deduplicateMessages)([...prev.messages, ...msgs]);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
merged = msgs;
|
|
89
|
+
}
|
|
90
|
+
if (msgs.length > 0) {
|
|
91
|
+
newestMessageIdRef.current = merged[merged.length - 1].id;
|
|
92
|
+
if (!opts.after) {
|
|
93
|
+
oldestMessageIdRef.current = merged[0].id;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
...prev,
|
|
98
|
+
messages: merged,
|
|
99
|
+
hasOlderMessages: res.hasMore ?? false,
|
|
100
|
+
loadingInitial: false,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error('[useRelayaChat] loadMessages failed:', err);
|
|
106
|
+
setState((prev) => ({ ...prev, loadingInitial: false }));
|
|
107
|
+
}
|
|
108
|
+
}, [api, stationSlug]);
|
|
109
|
+
// ── WebSocket message handler ──────────────────────────────────────────────
|
|
110
|
+
const handleWsMessage = (0, react_1.useCallback)((msg) => {
|
|
111
|
+
switch (msg.type) {
|
|
112
|
+
case 'auth:success': {
|
|
113
|
+
// Populate user directory
|
|
114
|
+
userDirectory.current.clear();
|
|
115
|
+
avatarHistory.current.clear();
|
|
116
|
+
msg.users.forEach((user) => {
|
|
117
|
+
userDirectory.current.set(user.id, {
|
|
118
|
+
id: user.id,
|
|
119
|
+
displayName: user.displayName,
|
|
120
|
+
avatarUrl: user.avatarUrl,
|
|
121
|
+
});
|
|
122
|
+
if (user.avatarUrl !== null) {
|
|
123
|
+
avatarHistory.current.set(user.id, [{ url: user.avatarUrl, changedAt: new Date(0) }]);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// Fresh connect: load initial messages. Reconnect: catch-up already in flight.
|
|
127
|
+
if (!newestMessageIdRef.current) {
|
|
128
|
+
setState((prev) => ({ ...prev, loadingInitial: true }));
|
|
129
|
+
loadMessages();
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'message:broadcast': {
|
|
134
|
+
const { message, clientId } = msg;
|
|
135
|
+
setState((prev) => {
|
|
136
|
+
const deduped = (0, core_1.deduplicateMessages)([...prev.messages, message]);
|
|
137
|
+
const newMessages = deduped.length > MAX_MESSAGES
|
|
138
|
+
? deduped.slice(deduped.length - MAX_MESSAGES)
|
|
139
|
+
: deduped;
|
|
140
|
+
const newOptimistic = (0, core_1.removeReconciledOptimistic)(prev.optimistic, clientId);
|
|
141
|
+
newestMessageIdRef.current = message.id;
|
|
142
|
+
if (newMessages.length > 0) {
|
|
143
|
+
oldestMessageIdRef.current = newMessages[0].id;
|
|
144
|
+
}
|
|
145
|
+
return { ...prev, messages: newMessages, optimistic: newOptimistic };
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'message:deleted': {
|
|
150
|
+
const { messageId } = msg;
|
|
151
|
+
setState((prev) => ({
|
|
152
|
+
...prev,
|
|
153
|
+
messages: prev.messages.map((m) => m.id === messageId
|
|
154
|
+
? { ...m, is_deleted: true, content: null }
|
|
155
|
+
: m),
|
|
156
|
+
}));
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'message:edited': {
|
|
160
|
+
const { message } = msg;
|
|
161
|
+
setState((prev) => ({
|
|
162
|
+
...prev,
|
|
163
|
+
messages: prev.messages.map((m) => (m.id === message.id ? message : m)),
|
|
164
|
+
}));
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'presence:update': {
|
|
168
|
+
msg.users.forEach((user) => {
|
|
169
|
+
userDirectory.current.set(user.id, {
|
|
170
|
+
id: user.id,
|
|
171
|
+
displayName: user.displayName,
|
|
172
|
+
avatarUrl: user.avatarUrl,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
setState((prev) => ({
|
|
176
|
+
...prev,
|
|
177
|
+
users: msg.users.map((u) => ({
|
|
178
|
+
id: u.id,
|
|
179
|
+
displayName: u.displayName,
|
|
180
|
+
avatarUrl: u.avatarUrl,
|
|
181
|
+
})),
|
|
182
|
+
userCount: msg.userCount,
|
|
183
|
+
totalCount: msg.totalCount,
|
|
184
|
+
}));
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'user:update': {
|
|
188
|
+
const { userId: updatedUserId, updates, timestamp } = msg;
|
|
189
|
+
const existing = userDirectory.current.get(updatedUserId);
|
|
190
|
+
userDirectory.current.set(updatedUserId, {
|
|
191
|
+
id: updatedUserId,
|
|
192
|
+
displayName: updates.displayName ?? existing?.displayName ?? 'Unknown User',
|
|
193
|
+
avatarUrl: updates.avatarUrl !== undefined
|
|
194
|
+
? updates.avatarUrl
|
|
195
|
+
: (existing?.avatarUrl ?? null),
|
|
196
|
+
});
|
|
197
|
+
if (updates.avatarUrl !== undefined) {
|
|
198
|
+
const history = avatarHistory.current.get(updatedUserId) || [];
|
|
199
|
+
history.push({ url: updates.avatarUrl, changedAt: new Date(timestamp) });
|
|
200
|
+
const capped = history.length > MAX_AVATAR_HISTORY
|
|
201
|
+
? history.slice(history.length - MAX_AVATAR_HISTORY)
|
|
202
|
+
: history;
|
|
203
|
+
avatarHistory.current.set(updatedUserId, capped);
|
|
204
|
+
}
|
|
205
|
+
setState((prev) => ({
|
|
206
|
+
...prev,
|
|
207
|
+
users: prev.users.map((u) => u.id === updatedUserId ? { ...u, ...updates } : u),
|
|
208
|
+
}));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'error': {
|
|
212
|
+
setState((prev) => {
|
|
213
|
+
const pending = prev.optimistic.filter((m) => m.status === 'sending');
|
|
214
|
+
if (pending.length === 1) {
|
|
215
|
+
return {
|
|
216
|
+
...prev,
|
|
217
|
+
optimistic: (0, core_1.markOptimisticFailed)(prev.optimistic, pending[0].clientId),
|
|
218
|
+
error: msg.message,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { ...prev, error: msg.message };
|
|
222
|
+
});
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
// mention:notification and channel:notification — host app handles audio
|
|
226
|
+
case 'mention:notification':
|
|
227
|
+
case 'channel:notification':
|
|
228
|
+
case 'stickers:updated':
|
|
229
|
+
default:
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}, [loadMessages]);
|
|
233
|
+
// ── Status change handler ──────────────────────────────────────────────────
|
|
234
|
+
const handleStatusChange = (0, react_1.useCallback)((status) => {
|
|
235
|
+
setState((prev) => ({ ...prev, connectionStatus: status }));
|
|
236
|
+
if (status === 'connected' && newestMessageIdRef.current) {
|
|
237
|
+
// Reconnected — catch up any messages we missed
|
|
238
|
+
loadMessages({ after: newestMessageIdRef.current });
|
|
239
|
+
}
|
|
240
|
+
}, [loadMessages]);
|
|
241
|
+
// ── Keep refs updated with latest callbacks ────────────────────────────────
|
|
242
|
+
(0, react_1.useEffect)(() => {
|
|
243
|
+
handleWsMessageRef.current = handleWsMessage;
|
|
244
|
+
handleStatusChangeRef.current = handleStatusChange;
|
|
245
|
+
});
|
|
246
|
+
// ── Connect / disconnect when auth changes ─────────────────────────────────
|
|
247
|
+
(0, react_1.useEffect)(() => {
|
|
248
|
+
// Don't connect while auth is still being determined
|
|
249
|
+
if (!stationSlug || authState.status === 'loading')
|
|
250
|
+
return;
|
|
251
|
+
const conn = new core_1.ChatConnection(() => {
|
|
252
|
+
const token = authState.status === 'authenticated' ? (getToken() ?? undefined) : undefined;
|
|
253
|
+
return (0, buildRnWsUrl_1.buildRnWsUrl)(serverUrl, stationSlug, token);
|
|
254
|
+
}, (msg) => handleWsMessageRef.current?.(msg), (status) => handleStatusChangeRef.current?.(status));
|
|
255
|
+
connRef.current = conn;
|
|
256
|
+
conn.connect();
|
|
257
|
+
return () => {
|
|
258
|
+
conn.close();
|
|
259
|
+
connRef.current = null;
|
|
260
|
+
};
|
|
261
|
+
}, [authState.status, stationSlug, serverUrl, getToken]);
|
|
262
|
+
// ── Load older messages ────────────────────────────────────────────────────
|
|
263
|
+
const loadOlderMessages = (0, react_1.useCallback)(async () => {
|
|
264
|
+
if (!stationSlug || !oldestMessageIdRef.current || state.loadingOlder)
|
|
265
|
+
return;
|
|
266
|
+
setState((prev) => ({ ...prev, loadingOlder: true }));
|
|
267
|
+
try {
|
|
268
|
+
const res = await api.getMessages(stationSlug, {
|
|
269
|
+
before: oldestMessageIdRef.current,
|
|
270
|
+
limit: PAGE_SIZE,
|
|
271
|
+
});
|
|
272
|
+
const older = res.messages ?? [];
|
|
273
|
+
setState((prev) => {
|
|
274
|
+
const merged = (0, core_1.deduplicateMessages)([...older, ...prev.messages]);
|
|
275
|
+
if (older.length > 0) {
|
|
276
|
+
oldestMessageIdRef.current = older[0].id;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
...prev,
|
|
280
|
+
messages: merged,
|
|
281
|
+
hasOlderMessages: res.hasMore ?? false,
|
|
282
|
+
loadingOlder: false,
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
setState((prev) => ({ ...prev, loadingOlder: false }));
|
|
288
|
+
}
|
|
289
|
+
}, [api, stationSlug, state.loadingOlder]);
|
|
290
|
+
// ── Send message (optimistic) ──────────────────────────────────────────────
|
|
291
|
+
const sendMessage = (0, react_1.useCallback)((content, replyTo) => {
|
|
292
|
+
if (!connRef.current || !authState.user)
|
|
293
|
+
return;
|
|
294
|
+
const clientId = (0, core_1.generateClientId)();
|
|
295
|
+
const optimisticMsg = {
|
|
296
|
+
clientId,
|
|
297
|
+
content: content.trim(),
|
|
298
|
+
authorId: userId,
|
|
299
|
+
authorDisplayName: authState.user.displayName,
|
|
300
|
+
authorAvatarUrl: authState.user.avatarUrl,
|
|
301
|
+
createdAt: new Date(),
|
|
302
|
+
status: 'sending',
|
|
303
|
+
};
|
|
304
|
+
setState((prev) => ({ ...prev, optimistic: [...prev.optimistic, optimisticMsg] }));
|
|
305
|
+
pendingClientIds.current.set(clientId, content.trim());
|
|
306
|
+
connRef.current.send({
|
|
307
|
+
type: 'message:send',
|
|
308
|
+
content: content.trim(),
|
|
309
|
+
clientId,
|
|
310
|
+
replyToMessageId: replyTo?.messageId,
|
|
311
|
+
replyAuthorName: replyTo?.authorName,
|
|
312
|
+
replyExcerpt: replyTo?.excerpt,
|
|
313
|
+
});
|
|
314
|
+
}, [authState.user, userId]);
|
|
315
|
+
// ── Moderator REST actions ─────────────────────────────────────────────────
|
|
316
|
+
const deleteMessage = (0, react_1.useCallback)(async (messageId) => {
|
|
317
|
+
if (!stationSlug)
|
|
318
|
+
return;
|
|
319
|
+
await api.deleteMessage(stationSlug, messageId);
|
|
320
|
+
setState((prev) => ({
|
|
321
|
+
...prev,
|
|
322
|
+
messages: prev.messages.map((m) => m.id === messageId
|
|
323
|
+
? { ...m, is_deleted: true, content: null }
|
|
324
|
+
: m),
|
|
325
|
+
}));
|
|
326
|
+
}, [api, stationSlug]);
|
|
327
|
+
const banUser = (0, react_1.useCallback)(async (targetUserId, params) => {
|
|
328
|
+
if (!stationSlug)
|
|
329
|
+
return;
|
|
330
|
+
await api.createBan(stationSlug, targetUserId, params);
|
|
331
|
+
}, [api, stationSlug]);
|
|
332
|
+
const reportMessage = (0, react_1.useCallback)(async (messageId, reason, details) => {
|
|
333
|
+
if (!stationSlug)
|
|
334
|
+
return;
|
|
335
|
+
await api.createReport(stationSlug, messageId, reason, details);
|
|
336
|
+
}, [api, stationSlug]);
|
|
337
|
+
const editMessage = (0, react_1.useCallback)(async (messageId, newContent) => {
|
|
338
|
+
if (!stationSlug)
|
|
339
|
+
return;
|
|
340
|
+
try {
|
|
341
|
+
const edited = await api.editMessage(stationSlug, messageId, newContent);
|
|
342
|
+
setState((prev) => ({
|
|
343
|
+
...prev,
|
|
344
|
+
messages: prev.messages.map((m) => m.id === messageId ? { ...m, ...edited } : m),
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
setState((prev) => ({ ...prev, error: 'Failed to edit message' }));
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
}, [api, stationSlug]);
|
|
352
|
+
// ── Expose user directory lookup ───────────────────────────────────────────
|
|
353
|
+
const getUserInfo = (0, react_1.useCallback)((userId) => userDirectory.current.get(userId), []);
|
|
354
|
+
return {
|
|
355
|
+
...state,
|
|
356
|
+
sendMessage,
|
|
357
|
+
loadOlderMessages,
|
|
358
|
+
editMessage,
|
|
359
|
+
deleteMessage,
|
|
360
|
+
banUser,
|
|
361
|
+
reportMessage,
|
|
362
|
+
getUserInfo,
|
|
363
|
+
getAvatarForMessage,
|
|
364
|
+
};
|
|
365
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @relaya-chat/react-native — public API surface
|
|
3
|
+
*
|
|
4
|
+
* Headless hooks and utilities for building Relaya chat in React Native / Expo.
|
|
5
|
+
* No UI components are exported — host apps render their own native components
|
|
6
|
+
* using the state and actions provided by these hooks.
|
|
7
|
+
*/
|
|
8
|
+
export { useRelayaAuth } from './hooks/useRelayaAuth';
|
|
9
|
+
export type { RelayaAuthOptions, RelayaAuthState, RelayaAuthActions, RelayaAuthUser, RelayaAuthStation, AuthStatus, } from './hooks/useRelayaAuth';
|
|
10
|
+
export { useRelayaChat } from './hooks/useRelayaChat';
|
|
11
|
+
export type { RelayaChatOptions, RelayaChatState, RelayaChatActions, OnlineUser, ReplyData, } from './hooks/useRelayaChat';
|
|
12
|
+
export { getMessageMenuItems } from './utils/getMessageMenuItems';
|
|
13
|
+
export type { MessageMenuItems, MessageMenuOpts, } from './utils/getMessageMenuItems';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAGH,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,UAAU,GACX,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,SAAS,GACV,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAClE,YAAY,EACV,gBAAgB,EAChB,eAAe,GAChB,MAAM,6BAA6B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
3
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
4
|
+
/**
|
|
5
|
+
* @relaya-chat/react-native — public API surface
|
|
6
|
+
*
|
|
7
|
+
* Headless hooks and utilities for building Relaya chat in React Native / Expo.
|
|
8
|
+
* No UI components are exported — host apps render their own native components
|
|
9
|
+
* using the state and actions provided by these hooks.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.getMessageMenuItems = exports.useRelayaChat = exports.useRelayaAuth = void 0;
|
|
13
|
+
// ── Auth hook ─────────────────────────────────────────────────────────────────
|
|
14
|
+
var useRelayaAuth_1 = require("./hooks/useRelayaAuth");
|
|
15
|
+
Object.defineProperty(exports, "useRelayaAuth", { enumerable: true, get: function () { return useRelayaAuth_1.useRelayaAuth; } });
|
|
16
|
+
// ── Chat hook ─────────────────────────────────────────────────────────────────
|
|
17
|
+
var useRelayaChat_1 = require("./hooks/useRelayaChat");
|
|
18
|
+
Object.defineProperty(exports, "useRelayaChat", { enumerable: true, get: function () { return useRelayaChat_1.useRelayaChat; } });
|
|
19
|
+
// ── Moderation utility ────────────────────────────────────────────────────────
|
|
20
|
+
var getMessageMenuItems_1 = require("./utils/getMessageMenuItems");
|
|
21
|
+
Object.defineProperty(exports, "getMessageMenuItems", { enumerable: true, get: function () { return getMessageMenuItems_1.getMessageMenuItems; } });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildRnWsUrl — constructs the WebSocket URL for React Native chat connections.
|
|
3
|
+
*
|
|
4
|
+
* Converts an http(s) serverUrl to ws(s) and appends query parameters:
|
|
5
|
+
* - token (Bearer JWT, URI-encoded) — omitted for anonymous connections
|
|
6
|
+
* - station (space slug, URI-encoded)
|
|
7
|
+
*
|
|
8
|
+
* This is an internal utility; not part of the public @relaya-chat/react-native API.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildRnWsUrl(serverUrl: string, stationSlug: string, token?: string): string;
|
|
11
|
+
//# sourceMappingURL=buildRnWsUrl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buildRnWsUrl.d.ts","sourceRoot":"","sources":["../../src/utils/buildRnWsUrl.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAKR"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
3
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
4
|
+
/**
|
|
5
|
+
* buildRnWsUrl — constructs the WebSocket URL for React Native chat connections.
|
|
6
|
+
*
|
|
7
|
+
* Converts an http(s) serverUrl to ws(s) and appends query parameters:
|
|
8
|
+
* - token (Bearer JWT, URI-encoded) — omitted for anonymous connections
|
|
9
|
+
* - station (space slug, URI-encoded)
|
|
10
|
+
*
|
|
11
|
+
* This is an internal utility; not part of the public @relaya-chat/react-native API.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.buildRnWsUrl = buildRnWsUrl;
|
|
15
|
+
function buildRnWsUrl(serverUrl, stationSlug, token) {
|
|
16
|
+
// Convert http(s):// → ws(s)://
|
|
17
|
+
const wsBase = serverUrl.replace(/^http/, 'ws');
|
|
18
|
+
const tokenParam = token ? `token=${encodeURIComponent(token)}&` : '';
|
|
19
|
+
return `${wsBase}/ws?${tokenParam}station=${encodeURIComponent(stationSlug)}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* getMessageMenuItems — pure moderation decision utility.
|
|
3
|
+
*
|
|
4
|
+
* Determines which context menu / action sheet items to show for a given
|
|
5
|
+
* message, based on ownership, permissions, and message state.
|
|
6
|
+
*
|
|
7
|
+
* This function is intentionally pure (no side effects, no platform deps)
|
|
8
|
+
* so it can be unit-tested without a React Native environment and shared
|
|
9
|
+
* across web and mobile surfaces to prevent moderation UX drift.
|
|
10
|
+
*/
|
|
11
|
+
import type { Message, Permission } from '@relaya-chat/core';
|
|
12
|
+
export interface MessageMenuItems {
|
|
13
|
+
showReply: boolean;
|
|
14
|
+
showEdit: boolean;
|
|
15
|
+
showDelete: boolean;
|
|
16
|
+
showReport: boolean;
|
|
17
|
+
showBan: boolean;
|
|
18
|
+
/** Avatar options — only shown on the current user's own messages */
|
|
19
|
+
showAvatarOptions: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface MessageMenuOpts {
|
|
22
|
+
message: Message;
|
|
23
|
+
currentUserId: string | null;
|
|
24
|
+
currentUserPermissions: Permission[];
|
|
25
|
+
currentUserPriority: number;
|
|
26
|
+
messageAuthorPriority: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function getMessageMenuItems(opts: MessageMenuOpts): MessageMenuItems;
|
|
29
|
+
//# sourceMappingURL=getMessageMenuItems.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"getMessageMenuItems.d.ts","sourceRoot":"","sources":["../../src/utils/getMessageMenuItems.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE7D,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,sBAAsB,EAAE,UAAU,EAAE,CAAC;IACrC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,eAAe,GAAG,gBAAgB,CA0C3E"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2026 JAB Ventures, Inc. MIT License.
|
|
3
|
+
// See LICENSE file at https://github.com/relaya-chat/sdk
|
|
4
|
+
/**
|
|
5
|
+
* getMessageMenuItems — pure moderation decision utility.
|
|
6
|
+
*
|
|
7
|
+
* Determines which context menu / action sheet items to show for a given
|
|
8
|
+
* message, based on ownership, permissions, and message state.
|
|
9
|
+
*
|
|
10
|
+
* This function is intentionally pure (no side effects, no platform deps)
|
|
11
|
+
* so it can be unit-tested without a React Native environment and shared
|
|
12
|
+
* across web and mobile surfaces to prevent moderation UX drift.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.getMessageMenuItems = getMessageMenuItems;
|
|
16
|
+
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
17
|
+
const MAX_EDITS = 2;
|
|
18
|
+
function getMessageMenuItems(opts) {
|
|
19
|
+
const { message, currentUserId, currentUserPermissions, currentUserPriority, messageAuthorPriority, } = opts;
|
|
20
|
+
const isOwn = !!currentUserId && message.user_id === currentUserId;
|
|
21
|
+
const isAnonymous = !currentUserId;
|
|
22
|
+
const isDeleted = message.is_deleted;
|
|
23
|
+
const hasPermission = (p) => currentUserPermissions.includes(p);
|
|
24
|
+
const withinEditWindow = !isDeleted &&
|
|
25
|
+
Date.now() - new Date(message.created_at).getTime() < EDIT_WINDOW_MS;
|
|
26
|
+
const editCountOk = (message.edit_count ?? 0) < MAX_EDITS;
|
|
27
|
+
// Ban is only available when the target has lower priority than the actor
|
|
28
|
+
const canBanTarget = messageAuthorPriority < currentUserPriority;
|
|
29
|
+
return {
|
|
30
|
+
showReply: !isAnonymous && !isDeleted,
|
|
31
|
+
showEdit: isOwn &&
|
|
32
|
+
withinEditWindow &&
|
|
33
|
+
editCountOk &&
|
|
34
|
+
hasPermission('chat.edit_own'),
|
|
35
|
+
showDelete: !isDeleted && (isOwn || hasPermission('chat.delete_any')),
|
|
36
|
+
showReport: !isOwn && !isAnonymous && !isDeleted,
|
|
37
|
+
showBan: !isOwn &&
|
|
38
|
+
!isAnonymous &&
|
|
39
|
+
hasPermission('chat.ban_user') &&
|
|
40
|
+
canBanTarget,
|
|
41
|
+
showAvatarOptions: isOwn,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relaya-chat/react-native",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Real-time community chat hooks for React Native and Expo — OTP auth, WebSocket messaging, moderation. Connects to the Relaya™ SaaS at api.relaya.chat. Headless: bring your own UI.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"chat", "real-time", "realtime", "community-chat", "react-native",
|
|
8
|
+
"expo", "websocket", "moderation", "radio", "live", "ugc",
|
|
9
|
+
"hooks", "headless"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/relaya-chat/sdk.git",
|
|
15
|
+
"directory": "packages/react-native"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://relaya.chat",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"lint": "eslint src --ext .ts"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@react-native-async-storage/async-storage": ">=1.18.0",
|
|
35
|
+
"react": ">=18.0.0",
|
|
36
|
+
"react-native": ">=0.72.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@relaya-chat/core": "^1.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@react-native-async-storage/async-storage": "^3.0.2",
|
|
43
|
+
"@types/react": "^18.3.0",
|
|
44
|
+
"@types/react-native": "^0.73.0",
|
|
45
|
+
"typescript": "^5.5.0",
|
|
46
|
+
"vitest": "^4.1.2"
|
|
47
|
+
}
|
|
48
|
+
}
|