@peers-app/peers-ui 0.23.14 → 0.24.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.
Files changed (93) hide show
  1. package/biome.json +2 -2
  2. package/dist/components/qr-scanner.d.ts +18 -0
  3. package/dist/components/qr-scanner.js +165 -0
  4. package/dist/components/router.d.ts +1 -1
  5. package/dist/components/router.js +1 -1
  6. package/dist/root-layout/root-layout-host.js +18 -19
  7. package/dist/root-layout/root-layout-host.test.js +14 -9
  8. package/dist/screens/account/account-screen.js +52 -1
  9. package/dist/screens/contacts/contact-list.js +28 -1
  10. package/dist/screens/contacts/index.d.ts +0 -2
  11. package/dist/screens/contacts/index.js +0 -2
  12. package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
  13. package/dist/screens/device-pairing/device-pairing.js +127 -128
  14. package/dist/screens/device-pairing/device-pairing.test.js +51 -15
  15. package/dist/screens/device-pairing/index.d.ts +0 -1
  16. package/dist/screens/device-pairing/index.js +0 -1
  17. package/dist/screens/groups/group-details.js +2 -6
  18. package/dist/screens/groups/group-invite-panel.d.ts +14 -0
  19. package/dist/screens/groups/group-invite-panel.js +125 -0
  20. package/dist/screens/groups/group-members.js +9 -103
  21. package/dist/screens/invites/accept-invite.d.ts +11 -0
  22. package/dist/screens/invites/accept-invite.js +114 -0
  23. package/dist/screens/invites/index.d.ts +7 -0
  24. package/dist/screens/{join-group → invites}/index.js +8 -3
  25. package/dist/screens/invites/invite-capture.d.ts +34 -0
  26. package/dist/screens/invites/invite-capture.js +94 -0
  27. package/dist/screens/invites/invite-share.d.ts +15 -0
  28. package/dist/screens/invites/invite-share.js +40 -0
  29. package/dist/screens/invites/invites-ipc.d.ts +10 -0
  30. package/dist/screens/invites/invites-ipc.js +61 -0
  31. package/dist/screens/invites/invites-screen.d.ts +9 -0
  32. package/dist/screens/invites/invites-screen.js +36 -0
  33. package/dist/screens/invites/pending-invites-list.d.ts +29 -0
  34. package/dist/screens/invites/pending-invites-list.js +156 -0
  35. package/dist/screens/settings/settings-page.js +1 -1
  36. package/dist/screens/setup-user.js +4 -18
  37. package/dist/system-apps/index.d.ts +1 -1
  38. package/dist/system-apps/index.js +5 -5
  39. package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
  40. package/dist/system-apps/invites.app.js +9 -0
  41. package/package.json +4 -3
  42. package/src/components/qr-scanner.tsx +180 -0
  43. package/src/components/router.tsx +1 -1
  44. package/src/root-layout/root-layout-host.test.tsx +18 -13
  45. package/src/root-layout/root-layout-host.tsx +23 -21
  46. package/src/screens/account/account-screen.tsx +89 -2
  47. package/src/screens/contacts/contact-list.tsx +64 -4
  48. package/src/screens/contacts/index.ts +0 -2
  49. package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
  50. package/src/screens/device-pairing/device-pairing.tsx +283 -208
  51. package/src/screens/device-pairing/index.ts +0 -1
  52. package/src/screens/groups/group-details.tsx +1 -41
  53. package/src/screens/groups/group-invite-panel.tsx +316 -0
  54. package/src/screens/groups/group-members.tsx +15 -177
  55. package/src/screens/invites/accept-invite.tsx +271 -0
  56. package/src/screens/invites/index.ts +9 -0
  57. package/src/screens/invites/invite-capture.ts +107 -0
  58. package/src/screens/invites/invite-share.tsx +104 -0
  59. package/src/screens/invites/invites-ipc.ts +74 -0
  60. package/src/screens/invites/invites-screen.tsx +78 -0
  61. package/src/screens/invites/pending-invites-list.tsx +224 -0
  62. package/src/screens/settings/settings-page.tsx +2 -2
  63. package/src/screens/setup-user.tsx +29 -47
  64. package/src/system-apps/index.ts +3 -3
  65. package/src/system-apps/invites.app.ts +8 -0
  66. package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
  67. package/dist/screens/contacts/user-connect-invite.js +0 -264
  68. package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
  69. package/dist/screens/contacts/user-connect-invite.test.js +0 -215
  70. package/dist/screens/contacts/user-connect.d.ts +0 -8
  71. package/dist/screens/contacts/user-connect.js +0 -487
  72. package/dist/screens/contacts/user-connect.test.d.ts +0 -1
  73. package/dist/screens/contacts/user-connect.test.js +0 -291
  74. package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
  75. package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
  76. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
  77. package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
  78. package/dist/screens/groups/group-invite-listener.d.ts +0 -14
  79. package/dist/screens/groups/group-invite-listener.js +0 -93
  80. package/dist/screens/join-group/index.d.ts +0 -2
  81. package/dist/screens/join-group/join-group.d.ts +0 -12
  82. package/dist/screens/join-group/join-group.js +0 -102
  83. package/dist/system-apps/join-group.app.js +0 -9
  84. package/src/screens/contacts/user-connect-invite.test.ts +0 -256
  85. package/src/screens/contacts/user-connect-invite.ts +0 -332
  86. package/src/screens/contacts/user-connect.test.tsx +0 -382
  87. package/src/screens/contacts/user-connect.tsx +0 -883
  88. package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
  89. package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
  90. package/src/screens/groups/group-invite-listener.tsx +0 -282
  91. package/src/screens/join-group/index.ts +0 -4
  92. package/src/screens/join-group/join-group.tsx +0 -296
  93. package/src/system-apps/join-group.app.ts +0 -8
@@ -1,50 +0,0 @@
1
- import {
2
- createDevicePairingInvitation,
3
- createDevicePairingInvitationUrl,
4
- parseDevicePairingInvitation,
5
- } from "@peers-app/peers-sdk";
6
- import {
7
- captureDevicePairingInviteFromLocation,
8
- clearPendingDevicePairingInvite,
9
- consumePendingDevicePairingInvite,
10
- readPendingDevicePairingInvite,
11
- } from "./device-pairing-invite";
12
-
13
- describe("device pairing invitation capture", () => {
14
- beforeEach(() => {
15
- clearPendingDevicePairingInvite();
16
- window.history.replaceState({}, "", "/?app");
17
- });
18
-
19
- it("captures the fragment only in memory and removes it from browser history", () => {
20
- const invitation = createDevicePairingInvitation({
21
- serviceOrigin: "https://peers.app",
22
- });
23
- const inviteUrl = createDevicePairingInvitationUrl(
24
- `${window.location.origin}/?app`,
25
- invitation,
26
- );
27
- window.history.replaceState(
28
- {},
29
- "",
30
- new URL(inviteUrl).pathname + new URL(inviteUrl).search + new URL(inviteUrl).hash,
31
- );
32
-
33
- const token = captureDevicePairingInviteFromLocation();
34
-
35
- expect(token).toBeDefined();
36
- expect(parseDevicePairingInvitation(token ?? "")).toEqual(invitation);
37
- expect(window.location.hash).toBe("");
38
- expect(readPendingDevicePairingInvite()).toBe(token);
39
- expect(consumePendingDevicePairingInvite()).toBe(token);
40
- expect(readPendingDevicePairingInvite()).toBeUndefined();
41
- });
42
-
43
- it("ignores invalid fragments without retaining them", () => {
44
- window.history.replaceState({}, "", "/?app#pair=invalid");
45
-
46
- expect(captureDevicePairingInviteFromLocation()).toBeUndefined();
47
- expect(readPendingDevicePairingInvite()).toBeUndefined();
48
- expect(window.location.hash).toBe("");
49
- });
50
- });
@@ -1,55 +0,0 @@
1
- import { encodeDevicePairingInvitation, parseDevicePairingInvitation } from "@peers-app/peers-sdk";
2
-
3
- let pendingInvitation: string | undefined;
4
-
5
- /** Browser event emitted after a pairing invitation is captured and removed from the URL. */
6
- export const DEVICE_PAIRING_INVITE_CAPTURED_EVENT = "device-pairing:invite-captured";
7
-
8
- /** Captures a pairing fragment in memory and immediately removes it from browser history. */
9
- export function captureDevicePairingInviteFromLocation(
10
- location: Location = window.location,
11
- ): string | undefined {
12
- if (!location.hash.startsWith("#pair=") && !location.hash.startsWith("#pair?")) {
13
- return undefined;
14
- }
15
- let capturedInvitation: string | undefined;
16
- try {
17
- capturedInvitation = encodeDevicePairingInvitation(parseDevicePairingInvitation(location.href));
18
- pendingInvitation = capturedInvitation;
19
- return capturedInvitation;
20
- } catch {
21
- return undefined;
22
- } finally {
23
- if (typeof window !== "undefined" && location === window.location) {
24
- window.history.replaceState(
25
- window.history.state,
26
- document.title,
27
- `${location.pathname}${location.search}`,
28
- );
29
- if (capturedInvitation) {
30
- window.dispatchEvent(
31
- new CustomEvent(DEVICE_PAIRING_INVITE_CAPTURED_EVENT, {
32
- detail: capturedInvitation,
33
- }),
34
- );
35
- }
36
- }
37
- }
38
- }
39
-
40
- /** Returns the in-memory invitation without consuming it. */
41
- export function readPendingDevicePairingInvite(): string | undefined {
42
- return pendingInvitation;
43
- }
44
-
45
- /** Returns and erases the in-memory invitation. */
46
- export function consumePendingDevicePairingInvite(): string | undefined {
47
- const invitation = pendingInvitation;
48
- pendingInvitation = undefined;
49
- return invitation;
50
- }
51
-
52
- /** Erases any unconsumed invitation. */
53
- export function clearPendingDevicePairingInvite(): void {
54
- pendingInvitation = undefined;
55
- }
@@ -1,282 +0,0 @@
1
- /**
2
- * Component for admins to prepare group invitations and manage join requests.
3
- *
4
- * Shows in the group details Members tab for users with Admin role or above.
5
- *
6
- * Communicates with device layer through pvars (not direct imports).
7
- */
8
-
9
- import {
10
- copyToClipboard,
11
- GroupMemberRole,
12
- generateInvitePassword,
13
- groupInviteListeners,
14
- groupInviteProcessRequest,
15
- groupInviteRequests,
16
- groupInviteStartListening,
17
- groupInviteStatus,
18
- groupInviteStopListening,
19
- type IGroupJoinRequest,
20
- type UserContext,
21
- } from "@peers-app/peers-sdk";
22
- import { useCallback, useEffect, useState } from "react";
23
- import { useObservable } from "../../hooks";
24
-
25
- interface GroupInviteListenerProps {
26
- groupId: string;
27
- userContext: UserContext;
28
- }
29
-
30
- export const GroupInviteListener = (props: GroupInviteListenerProps) => {
31
- const { groupId } = props;
32
-
33
- // State
34
- const [password, setPassword] = useState<string>("");
35
- const [isListening, setIsListening] = useState(false);
36
- const [copied, setCopied] = useState(false);
37
- const [approvalRole, setApprovalRole] = useState<GroupMemberRole>(GroupMemberRole.Reader);
38
- const [processingRequestId, setProcessingRequestId] = useState<string | null>(null);
39
-
40
- // Observables
41
- const [listeners] = useObservable(groupInviteListeners);
42
- const [requests] = useObservable(groupInviteRequests);
43
- const [status] = useObservable(groupInviteStatus);
44
-
45
- // Check if we're currently listening for this group
46
- const currentListener = listeners?.[groupId];
47
- const pendingRequests = (requests || []).filter((r) => r.groupId === groupId);
48
-
49
- // Sync local state with listener state
50
- useEffect(() => {
51
- if (currentListener) {
52
- setPassword(currentListener.password);
53
- setIsListening(true);
54
- } else {
55
- setIsListening(false);
56
- }
57
- }, [currentListener]);
58
-
59
- // Generate a new password
60
- const handleGeneratePassword = useCallback(() => {
61
- const newPassword = generateInvitePassword();
62
- setPassword(newPassword);
63
- }, []);
64
-
65
- // Start listening for invitations (set pvar, device layer reacts)
66
- const handleStartListening = useCallback(() => {
67
- if (!password.trim() || password.length < 4) {
68
- alert("Password must be at least 4 characters");
69
- return;
70
- }
71
-
72
- // Set the action pvar - device layer will process it
73
- groupInviteStartListening({ groupId, password });
74
- }, [groupId, password]);
75
-
76
- // Stop listening (set pvar, device layer reacts)
77
- const handleStopListening = useCallback(() => {
78
- // Set the action pvar - device layer will process it
79
- groupInviteStopListening(groupId);
80
- }, [groupId]);
81
-
82
- // Copy password to clipboard
83
- const handleCopyPassword = useCallback(async () => {
84
- const success = await copyToClipboard(password);
85
- if (success) {
86
- setCopied(true);
87
- setTimeout(() => setCopied(false), 2000);
88
- }
89
- }, [password]);
90
-
91
- // Approve a join request (set pvar, device layer reacts)
92
- const handleApprove = useCallback(
93
- (request: IGroupJoinRequest) => {
94
- setProcessingRequestId(request.requestId);
95
- // Set the action pvar - device layer will process it
96
- groupInviteProcessRequest({
97
- requestId: request.requestId,
98
- approved: true,
99
- role: approvalRole,
100
- });
101
- // Clear processing state after a short delay
102
- setTimeout(() => setProcessingRequestId(null), 1000);
103
- },
104
- [approvalRole],
105
- );
106
-
107
- // Deny a join request (set pvar, device layer reacts)
108
- const handleDeny = useCallback((request: IGroupJoinRequest) => {
109
- setProcessingRequestId(request.requestId);
110
- // Set the action pvar - device layer will process it
111
- groupInviteProcessRequest({
112
- requestId: request.requestId,
113
- approved: false,
114
- role: GroupMemberRole.None,
115
- });
116
- // Clear processing state after a short delay
117
- setTimeout(() => setProcessingRequestId(null), 1000);
118
- }, []);
119
-
120
- return (
121
- <div className="card mb-3">
122
- <div className="card-header">
123
- <h6 className="mb-0">
124
- <i className="bi-person-plus-fill me-2" />
125
- Invite New Members
126
- </h6>
127
- </div>
128
- <div className="card-body">
129
- {/* Password input and controls */}
130
- <div className="mb-3">
131
- <label className="form-label small text-muted">Invitation Password</label>
132
- <div className="input-group">
133
- <input
134
- type="text"
135
- className="form-control font-monospace"
136
- value={password}
137
- onChange={(e) => setPassword(e.target.value)}
138
- placeholder="Enter or generate a password..."
139
- disabled={isListening}
140
- />
141
- <button
142
- className="btn btn-outline-secondary"
143
- onClick={handleGeneratePassword}
144
- disabled={isListening}
145
- title="Generate random password"
146
- >
147
- <i className="bi-shuffle" />
148
- </button>
149
- <button
150
- className="btn btn-outline-secondary"
151
- onClick={handleCopyPassword}
152
- disabled={!password}
153
- title="Copy password"
154
- >
155
- <i className={copied ? "bi-check-lg text-success" : "bi-clipboard"} />
156
- </button>
157
- </div>
158
- <small className="text-muted">
159
- Share this password with people you want to invite. They can enter it in the "Join
160
- Group" screen.
161
- </small>
162
- </div>
163
-
164
- {/* Start/Stop listening button */}
165
- <div className="d-flex gap-2">
166
- {!isListening ? (
167
- <button
168
- className="btn btn-primary"
169
- onClick={handleStartListening}
170
- disabled={!password || password.length < 4}
171
- >
172
- <i className="bi-broadcast me-2" />
173
- Start Listening
174
- </button>
175
- ) : (
176
- <button className="btn btn-danger" onClick={handleStopListening}>
177
- <i className="bi-stop-circle me-2" />
178
- Stop Listening
179
- </button>
180
- )}
181
- </div>
182
-
183
- {/* Listening status */}
184
- {isListening && (
185
- <div className="alert alert-success mt-3 mb-0 d-flex align-items-center">
186
- <div className="spinner-border spinner-border-sm me-2" role="status">
187
- <span className="visually-hidden">Listening...</span>
188
- </div>
189
- <div>
190
- <strong>Listening for join requests</strong>
191
- <br />
192
- <small>
193
- Password: <code>{password}</code>
194
- </small>
195
- </div>
196
- </div>
197
- )}
198
-
199
- {/* Status message */}
200
- {status && (
201
- <div className="alert alert-info mt-3 mb-0">
202
- <i className="bi-info-circle me-2" />
203
- {status}
204
- </div>
205
- )}
206
-
207
- {/* Pending join requests */}
208
- {pendingRequests.length > 0 && (
209
- <div className="mt-3">
210
- <h6 className="mb-2">
211
- <i className="bi-inbox-fill me-2" />
212
- Pending Requests ({pendingRequests.length})
213
- </h6>
214
-
215
- {/* Role selector for approvals */}
216
- <div className="mb-2">
217
- <label className="form-label small text-muted">Role for new members:</label>
218
- <select
219
- className="form-select form-select-sm"
220
- value={approvalRole}
221
- onChange={(e) => setApprovalRole(Number(e.target.value) as GroupMemberRole)}
222
- style={{ width: "auto" }}
223
- >
224
- <option value={GroupMemberRole.Reader}>Reader</option>
225
- <option value={GroupMemberRole.Writer}>Writer</option>
226
- <option value={GroupMemberRole.Admin}>Admin</option>
227
- </select>
228
- </div>
229
-
230
- <div className="list-group">
231
- {pendingRequests.map((request) => (
232
- <div key={request.requestId} className="list-group-item">
233
- <div className="d-flex justify-content-between align-items-center">
234
- <div>
235
- <div className="d-flex align-items-center">
236
- <i className="bi-person-fill me-2" />
237
- <div>
238
- <strong>{request.requester.name || request.requester.userId}</strong>
239
- {request.requester.name && (
240
- <small className="text-muted d-block">{request.requester.userId}</small>
241
- )}
242
- </div>
243
- </div>
244
- <small className="text-muted">
245
- Device: {request.requester.deviceId.slice(0, 8)}...
246
- {" | "}
247
- Received: {new Date(request.receivedAt).toLocaleTimeString()}
248
- </small>
249
- </div>
250
- <div className="d-flex gap-2">
251
- <button
252
- className="btn btn-success btn-sm"
253
- onClick={() => handleApprove(request)}
254
- disabled={processingRequestId === request.requestId}
255
- >
256
- {processingRequestId === request.requestId ? (
257
- <span className="spinner-border spinner-border-sm" />
258
- ) : (
259
- <>
260
- <i className="bi-check-lg me-1" />
261
- Approve
262
- </>
263
- )}
264
- </button>
265
- <button
266
- className="btn btn-outline-danger btn-sm"
267
- onClick={() => handleDeny(request)}
268
- disabled={processingRequestId === request.requestId}
269
- >
270
- <i className="bi-x-lg" />
271
- </button>
272
- </div>
273
- </div>
274
- </div>
275
- ))}
276
- </div>
277
- </div>
278
- )}
279
- </div>
280
- </div>
281
- );
282
- };
@@ -1,4 +0,0 @@
1
- // Import to ensure route registration
2
- import "./join-group";
3
-
4
- export * from "./join-group";
@@ -1,296 +0,0 @@
1
- /**
2
- * Join Group screen - allows users to join a group by entering an invitation password.
3
- *
4
- * Flow:
5
- * 1. User enters the invitation password
6
- * 2. System discovers all devices listening for that password
7
- * 3. User selects a group/device to join
8
- * 4. System sends join request and waits for approval
9
- *
10
- * Communicates with device layer through pvars (not direct imports).
11
- */
12
-
13
- import {
14
- getUserContext,
15
- groupInviteDiscover,
16
- groupInviteDiscoveryResult,
17
- groupInviteJoinResult,
18
- groupInviteSendRequest,
19
- groupJoinStatus,
20
- type IGroupInviteListenerInfo,
21
- isValidInvitePassword,
22
- } from "@peers-app/peers-sdk";
23
- import type React from "react";
24
- import { useCallback, useEffect, useState } from "react";
25
- import { LoadingIndicator } from "../../components/loading-indicator";
26
- import { mainContentPath } from "../../globals";
27
- import { useObservable, usePromise } from "../../hooks";
28
- import { registerInternalPeersUI } from "../../ui-router/ui-loader";
29
-
30
- export const JoinGroup = () => {
31
- const userContext = usePromise(() => getUserContext());
32
-
33
- // State
34
- const [password, setPassword] = useState("");
35
- const [isSearching, setIsSearching] = useState(false);
36
- const [isJoining, setIsJoining] = useState(false);
37
- const [error, setError] = useState<string | null>(null);
38
- const [_currentJoiningGroup, setCurrentJoiningGroup] = useState<IGroupInviteListenerInfo | null>(
39
- null,
40
- );
41
-
42
- // Observable state from device layer
43
- const [status] = useObservable(groupJoinStatus);
44
- const [discoveredListeners] = useObservable(groupInviteDiscoveryResult);
45
- const [joinResult] = useObservable(groupInviteJoinResult);
46
-
47
- // Watch for join results
48
- useEffect(() => {
49
- if (isJoining && joinResult) {
50
- setIsJoining(false);
51
- if (joinResult.success && joinResult.groupId) {
52
- // Navigate to the group after a brief delay
53
- setTimeout(() => {
54
- mainContentPath(`groups/${joinResult.groupId}`);
55
- }, 2000);
56
- }
57
- }
58
- }, [joinResult, isJoining]);
59
-
60
- // Search for groups (set pvar, device layer reacts)
61
- const handleSearch = useCallback(() => {
62
- if (!isValidInvitePassword(password)) {
63
- setError("Password must be at least 4 characters");
64
- return;
65
- }
66
-
67
- setError(null);
68
- setIsSearching(true);
69
-
70
- // Clear previous results and trigger discovery
71
- groupInviteDiscoveryResult([]);
72
- groupInviteJoinResult(null);
73
- groupInviteDiscover(password);
74
- }, [password]);
75
-
76
- // Join a group (set pvar, device layer reacts)
77
- const handleJoin = useCallback(
78
- (listener: IGroupInviteListenerInfo) => {
79
- setIsJoining(true);
80
- setError(null);
81
- setCurrentJoiningGroup(listener);
82
-
83
- // Clear previous result and trigger join request
84
- groupInviteJoinResult(null);
85
- groupInviteSendRequest({ listener, password });
86
- },
87
- [password],
88
- );
89
-
90
- // Reset state
91
- const handleReset = useCallback(() => {
92
- setPassword("");
93
- setError(null);
94
- setCurrentJoiningGroup(null);
95
- setIsSearching(false);
96
- setIsJoining(false);
97
- // Clear pvars
98
- groupInviteDiscoveryResult([]);
99
- groupInviteJoinResult(null);
100
- }, []);
101
-
102
- // Handle Enter key
103
- const handleKeyDown = useCallback(
104
- (e: React.KeyboardEvent) => {
105
- if (e.key === "Enter" && password && !isSearching) {
106
- handleSearch();
107
- }
108
- },
109
- [password, isSearching, handleSearch],
110
- );
111
-
112
- if (!userContext) {
113
- return <LoadingIndicator />;
114
- }
115
-
116
- return (
117
- <div className="container-fluid p-4" style={{ maxWidth: "600px" }}>
118
- <div className="text-center mb-4">
119
- <h2>
120
- <i className="bi-box-arrow-in-right me-2" />
121
- Join Group
122
- </h2>
123
- <p className="text-muted">Enter the invitation password to find and join a group</p>
124
- </div>
125
-
126
- {/* Success state */}
127
- {joinResult?.success && (
128
- <div className="text-center">
129
- <div className="mb-4">
130
- <i className="bi-check-circle-fill text-success" style={{ fontSize: "4rem" }} />
131
- </div>
132
- <h4 className="text-success mb-3">Successfully Joined!</h4>
133
- <p className="text-muted">Redirecting to the group...</p>
134
- <button className="btn btn-outline-primary" onClick={handleReset}>
135
- <i className="bi-plus-lg me-2" />
136
- Join Another Group
137
- </button>
138
- </div>
139
- )}
140
-
141
- {/* Main form */}
142
- {!joinResult?.success && (
143
- <>
144
- {/* Password input */}
145
- <div className="card mb-4">
146
- <div className="card-body">
147
- <label className="form-label">
148
- <i className="bi-key-fill me-2" />
149
- Invitation Password
150
- </label>
151
- <div className="input-group">
152
- <input
153
- type="text"
154
- className="form-control form-control-lg font-monospace"
155
- value={password}
156
- onChange={(e) => setPassword(e.target.value)}
157
- onKeyDown={handleKeyDown}
158
- placeholder="Enter password (e.g., apple-banana-cherry-date)"
159
- disabled={isSearching || isJoining}
160
- />
161
- <button
162
- className="btn btn-primary"
163
- onClick={handleSearch}
164
- disabled={!password || isSearching || isJoining}
165
- >
166
- {isSearching ? (
167
- <>
168
- <span className="spinner-border spinner-border-sm me-2" />
169
- Searching...
170
- </>
171
- ) : (
172
- <>
173
- <i className="bi-search me-2" />
174
- Search
175
- </>
176
- )}
177
- </button>
178
- </div>
179
- <small className="text-muted">Ask the group admin for the invitation password</small>
180
- </div>
181
- </div>
182
-
183
- {/* Error message */}
184
- {error && (
185
- <div className="alert alert-danger">
186
- <i className="bi-exclamation-triangle me-2" />
187
- {error}
188
- </div>
189
- )}
190
-
191
- {/* Status message */}
192
- {status && !error && (
193
- <div className="alert alert-info">
194
- <i className="bi-info-circle me-2" />
195
- {status}
196
- </div>
197
- )}
198
-
199
- {/* Join result error */}
200
- {joinResult && !joinResult.success && (
201
- <div className="alert alert-warning">
202
- <i className="bi-exclamation-circle me-2" />
203
- {joinResult.error || "Join request was not approved"}
204
- </div>
205
- )}
206
-
207
- {/* Discovered groups */}
208
- {discoveredListeners && discoveredListeners.length > 0 && (
209
- <div className="card">
210
- <div className="card-header">
211
- <h6 className="mb-0">
212
- <i className="bi-broadcast me-2" />
213
- Found Groups ({discoveredListeners.length})
214
- </h6>
215
- </div>
216
- <div className="list-group list-group-flush">
217
- {discoveredListeners.map((listener, index) => (
218
- <div key={`${listener.deviceId}-${index}`} className="list-group-item">
219
- <div className="d-flex justify-content-between align-items-center">
220
- <div>
221
- <div className="d-flex align-items-center mb-1">
222
- <i
223
- className={`${listener.group.iconClassName || "bi-people-fill"} me-2`}
224
- />
225
- <strong>{listener.group.name}</strong>
226
- </div>
227
- {listener.group.description && (
228
- <p className="text-muted mb-1 small">{listener.group.description}</p>
229
- )}
230
- <small className="text-muted">
231
- <i className="bi-person me-1" />
232
- Invited by: {listener.name || listener.userId}
233
- </small>
234
- </div>
235
- <button
236
- className="btn btn-success"
237
- onClick={() => handleJoin(listener)}
238
- disabled={isJoining}
239
- >
240
- {isJoining ? (
241
- <>
242
- <span className="spinner-border spinner-border-sm me-2" />
243
- Joining...
244
- </>
245
- ) : (
246
- <>
247
- <i className="bi-box-arrow-in-right me-2" />
248
- Join
249
- </>
250
- )}
251
- </button>
252
- </div>
253
- </div>
254
- ))}
255
- </div>
256
- </div>
257
- )}
258
-
259
- {/* Searching indicator */}
260
- {isSearching && (
261
- <div className="text-center py-4">
262
- <div className="spinner-border text-primary mb-3" role="status">
263
- <span className="visually-hidden">Searching...</span>
264
- </div>
265
- <p className="text-muted">Searching for groups...</p>
266
- </div>
267
- )}
268
-
269
- {/* Joining indicator */}
270
- {isJoining && (
271
- <div className="text-center py-4">
272
- <div className="spinner-border text-success mb-3" role="status">
273
- <span className="visually-hidden">Joining...</span>
274
- </div>
275
- <p className="text-muted">Waiting for approval...</p>
276
- <small className="text-muted">The group admin needs to approve your request</small>
277
- </div>
278
- )}
279
- </>
280
- )}
281
- </div>
282
- );
283
- };
284
-
285
- // Register the route
286
- registerInternalPeersUI({
287
- peersUIId: "00join0group0screen000000",
288
- component: JoinGroup,
289
- routes: [
290
- {
291
- isMatch: (_props, context) => context.path === "join-group",
292
- uiCategory: "screen",
293
- priority: 2,
294
- },
295
- ],
296
- });