@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.
- package/biome.json +2 -2
- package/dist/components/qr-scanner.d.ts +18 -0
- package/dist/components/qr-scanner.js +165 -0
- package/dist/components/router.d.ts +1 -1
- package/dist/components/router.js +1 -1
- package/dist/root-layout/root-layout-host.js +18 -19
- package/dist/root-layout/root-layout-host.test.js +14 -9
- package/dist/screens/account/account-screen.js +52 -1
- package/dist/screens/contacts/contact-list.js +28 -1
- package/dist/screens/contacts/index.d.ts +0 -2
- package/dist/screens/contacts/index.js +0 -2
- package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
- package/dist/screens/device-pairing/device-pairing.js +127 -128
- package/dist/screens/device-pairing/device-pairing.test.js +51 -15
- package/dist/screens/device-pairing/index.d.ts +0 -1
- package/dist/screens/device-pairing/index.js +0 -1
- package/dist/screens/groups/group-details.js +2 -6
- package/dist/screens/groups/group-invite-panel.d.ts +14 -0
- package/dist/screens/groups/group-invite-panel.js +125 -0
- package/dist/screens/groups/group-members.js +9 -103
- package/dist/screens/invites/accept-invite.d.ts +11 -0
- package/dist/screens/invites/accept-invite.js +114 -0
- package/dist/screens/invites/index.d.ts +7 -0
- package/dist/screens/{join-group → invites}/index.js +8 -3
- package/dist/screens/invites/invite-capture.d.ts +34 -0
- package/dist/screens/invites/invite-capture.js +94 -0
- package/dist/screens/invites/invite-share.d.ts +15 -0
- package/dist/screens/invites/invite-share.js +40 -0
- package/dist/screens/invites/invites-ipc.d.ts +10 -0
- package/dist/screens/invites/invites-ipc.js +61 -0
- package/dist/screens/invites/invites-screen.d.ts +9 -0
- package/dist/screens/invites/invites-screen.js +36 -0
- package/dist/screens/invites/pending-invites-list.d.ts +29 -0
- package/dist/screens/invites/pending-invites-list.js +156 -0
- package/dist/screens/settings/settings-page.js +1 -1
- package/dist/screens/setup-user.js +4 -18
- package/dist/system-apps/index.d.ts +1 -1
- package/dist/system-apps/index.js +5 -5
- package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
- package/dist/system-apps/invites.app.js +9 -0
- package/package.json +4 -3
- package/src/components/qr-scanner.tsx +180 -0
- package/src/components/router.tsx +1 -1
- package/src/root-layout/root-layout-host.test.tsx +18 -13
- package/src/root-layout/root-layout-host.tsx +23 -21
- package/src/screens/account/account-screen.tsx +89 -2
- package/src/screens/contacts/contact-list.tsx +64 -4
- package/src/screens/contacts/index.ts +0 -2
- package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
- package/src/screens/device-pairing/device-pairing.tsx +283 -208
- package/src/screens/device-pairing/index.ts +0 -1
- package/src/screens/groups/group-details.tsx +1 -41
- package/src/screens/groups/group-invite-panel.tsx +316 -0
- package/src/screens/groups/group-members.tsx +15 -177
- package/src/screens/invites/accept-invite.tsx +271 -0
- package/src/screens/invites/index.ts +9 -0
- package/src/screens/invites/invite-capture.ts +107 -0
- package/src/screens/invites/invite-share.tsx +104 -0
- package/src/screens/invites/invites-ipc.ts +74 -0
- package/src/screens/invites/invites-screen.tsx +78 -0
- package/src/screens/invites/pending-invites-list.tsx +224 -0
- package/src/screens/settings/settings-page.tsx +2 -2
- package/src/screens/setup-user.tsx +29 -47
- package/src/system-apps/index.ts +3 -3
- package/src/system-apps/invites.app.ts +8 -0
- package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
- package/dist/screens/contacts/user-connect-invite.js +0 -264
- package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
- package/dist/screens/contacts/user-connect-invite.test.js +0 -215
- package/dist/screens/contacts/user-connect.d.ts +0 -8
- package/dist/screens/contacts/user-connect.js +0 -487
- package/dist/screens/contacts/user-connect.test.d.ts +0 -1
- package/dist/screens/contacts/user-connect.test.js +0 -291
- package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
- package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
- package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
- package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
- package/dist/screens/groups/group-invite-listener.d.ts +0 -14
- package/dist/screens/groups/group-invite-listener.js +0 -93
- package/dist/screens/join-group/index.d.ts +0 -2
- package/dist/screens/join-group/join-group.d.ts +0 -12
- package/dist/screens/join-group/join-group.js +0 -102
- package/dist/system-apps/join-group.app.js +0 -9
- package/src/screens/contacts/user-connect-invite.test.ts +0 -256
- package/src/screens/contacts/user-connect-invite.ts +0 -332
- package/src/screens/contacts/user-connect.test.tsx +0 -382
- package/src/screens/contacts/user-connect.tsx +0 -883
- package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
- package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
- package/src/screens/groups/group-invite-listener.tsx +0 -282
- package/src/screens/join-group/index.ts +0 -4
- package/src/screens/join-group/join-group.tsx +0 -296
- 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,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
|
-
});
|