@peers-app/peers-ui 0.25.2 → 0.25.3
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/README.md +2 -1
- package/dist/components/markdown-editor/mention-node.d.ts +4 -0
- package/dist/components/markdown-editor/mention-node.js +4 -1
- package/dist/components/responsive-section-nav.d.ts +34 -0
- package/dist/components/responsive-section-nav.js +19 -0
- package/dist/components/responsive-section-nav.test.d.ts +1 -0
- package/dist/components/responsive-section-nav.test.js +45 -0
- package/dist/components/router.d.ts +2 -3
- package/dist/components/router.js +6 -9
- package/dist/globals-hash.test.d.ts +1 -0
- package/dist/globals-hash.test.js +16 -0
- package/dist/globals.d.ts +19 -0
- package/dist/globals.js +51 -12
- package/dist/hooks/use-host-contract.d.ts +18 -0
- package/dist/hooks/use-host-contract.js +53 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/peers-services-registration.d.ts +1 -5
- package/dist/peers-services-registration.js +6 -16
- package/dist/root-layout/root-layout-host.js +7 -4
- package/dist/root-layout/root-layout-host.test.js +3 -6
- package/dist/screens/account/account-screen.d.ts +1 -0
- package/dist/screens/account/account-screen.js +29 -53
- package/dist/screens/account/account-screen.test.d.ts +1 -0
- package/dist/screens/account/account-screen.test.js +47 -0
- package/dist/screens/contacts/contact-details.js +2 -23
- package/dist/screens/contacts/contact-list.d.ts +24 -1
- package/dist/screens/contacts/contact-list.js +128 -143
- package/dist/screens/contacts/contact-list.test.d.ts +1 -0
- package/dist/screens/contacts/contact-list.test.js +48 -0
- package/dist/screens/contacts/index.d.ts +0 -2
- package/dist/screens/contacts/index.js +0 -3
- package/dist/screens/device-pairing/device-pairing.js +17 -16
- package/dist/screens/device-pairing/device-pairing.test.js +4 -9
- package/dist/screens/device-pairing/identity-ipc.d.ts +11 -0
- package/dist/screens/device-pairing/identity-ipc.js +20 -0
- package/dist/screens/device-pairing/use-device-pairing-state.d.ts +2 -2
- package/dist/screens/device-pairing/use-device-pairing-state.js +7 -25
- package/dist/screens/devices/devices-list.d.ts +18 -0
- package/dist/screens/devices/devices-list.js +173 -0
- package/dist/screens/devices/devices-list.test.d.ts +1 -0
- package/dist/screens/devices/devices-list.test.js +53 -0
- package/dist/screens/devices/index.d.ts +1 -0
- package/dist/screens/devices/index.js +5 -0
- package/dist/screens/groups/group-details.d.ts +1 -0
- package/dist/screens/groups/group-details.js +4 -63
- package/dist/screens/groups/group-invite-panel.js +2 -2
- package/dist/screens/groups/group-list.d.ts +18 -1
- package/dist/screens/groups/group-list.js +107 -136
- package/dist/screens/groups/group-list.test.d.ts +1 -0
- package/dist/screens/groups/group-list.test.js +38 -0
- package/dist/screens/groups/group-members.d.ts +1 -0
- package/dist/screens/groups/group-members.js +2 -1
- package/dist/screens/groups/index.d.ts +0 -3
- package/dist/screens/groups/index.js +0 -4
- package/dist/screens/identity/identity-routes.d.ts +107 -0
- package/dist/screens/identity/identity-routes.js +245 -0
- package/dist/screens/identity/identity-routes.test.d.ts +1 -0
- package/dist/screens/identity/identity-routes.test.js +51 -0
- package/dist/screens/identity/identity-screen.d.ts +9 -0
- package/dist/screens/identity/identity-screen.js +143 -0
- package/dist/screens/identity/identity-screen.test.d.ts +1 -0
- package/dist/screens/identity/identity-screen.test.js +103 -0
- package/dist/screens/identity/index.d.ts +2 -0
- package/dist/screens/identity/index.js +6 -0
- package/dist/screens/identity/invite-contact-panel.d.ts +4 -0
- package/dist/screens/identity/invite-contact-panel.js +30 -0
- package/dist/screens/identity/profile-invite.d.ts +7 -0
- package/dist/screens/identity/profile-invite.js +62 -0
- package/dist/screens/invites/accept-invite.js +1 -1
- package/dist/screens/invites/activity-view.d.ts +24 -0
- package/dist/screens/invites/activity-view.js +64 -0
- package/dist/screens/invites/activity-view.test.d.ts +1 -0
- package/dist/screens/invites/activity-view.test.js +108 -0
- package/dist/screens/invites/index.d.ts +1 -2
- package/dist/screens/invites/index.js +1 -3
- package/dist/screens/invites/invite-capture.d.ts +0 -2
- package/dist/screens/invites/invite-capture.js +1 -3
- package/dist/screens/invites/invite-classify.test.d.ts +1 -0
- package/dist/screens/invites/invite-classify.test.js +98 -0
- package/dist/screens/invites/invites-ipc.d.ts +1 -1
- package/dist/screens/invites/invites-ipc.js +5 -45
- package/dist/screens/invites/pending-invites-list.d.ts +35 -16
- package/dist/screens/invites/pending-invites-list.js +69 -30
- package/dist/screens/network-viewer/network-viewer-ipc.js +4 -59
- package/dist/screens/search/global-search.d.ts +1 -0
- package/dist/screens/search/global-search.js +2 -1
- package/dist/screens/settings/settings-page.js +25 -58
- package/dist/screens/setup-user.js +5 -5
- package/dist/screens/tools/tool-access-ipc.d.ts +2 -0
- package/dist/screens/tools/tool-access-ipc.js +8 -55
- package/dist/screens/welcome-modal.js +3 -2
- package/dist/system-apps/identity.app.d.ts +3 -0
- package/dist/system-apps/identity.app.js +10 -0
- package/dist/system-apps/index.d.ts +1 -4
- package/dist/system-apps/index.js +5 -17
- package/dist/tabs-layout/network-status-indicator.js +10 -3
- package/dist/tabs-layout/network-status-indicator.test.js +15 -0
- package/dist/tabs-layout/tabs-layout.js +3 -1
- package/dist/tabs-layout/tabs-state.d.ts +79 -1
- package/dist/tabs-layout/tabs-state.js +256 -79
- package/dist/tabs-layout/tabs-state.test.d.ts +1 -0
- package/dist/tabs-layout/tabs-state.test.js +110 -0
- package/package.json +3 -3
- package/src/components/markdown-editor/mention-node.ts +5 -1
- package/src/components/responsive-section-nav.test.tsx +88 -0
- package/src/components/responsive-section-nav.tsx +92 -0
- package/src/components/router.tsx +6 -9
- package/src/globals-hash.test.ts +19 -0
- package/src/globals.tsx +53 -13
- package/src/hooks/use-host-contract.ts +75 -0
- package/src/index.tsx +1 -0
- package/src/peers-services-registration.ts +6 -17
- package/src/root-layout/root-layout-host.test.tsx +4 -7
- package/src/root-layout/root-layout-host.tsx +9 -11
- package/src/screens/account/account-screen.test.tsx +61 -0
- package/src/screens/account/account-screen.tsx +116 -95
- package/src/screens/contacts/contact-details.tsx +4 -26
- package/src/screens/contacts/contact-list.test.tsx +59 -0
- package/src/screens/contacts/contact-list.tsx +246 -260
- package/src/screens/contacts/index.ts +0 -4
- package/src/screens/device-pairing/device-pairing.test.tsx +7 -9
- package/src/screens/device-pairing/device-pairing.tsx +19 -23
- package/src/screens/device-pairing/identity-ipc.ts +25 -0
- package/src/screens/device-pairing/use-device-pairing-state.ts +10 -33
- package/src/screens/devices/devices-list.test.tsx +68 -0
- package/src/screens/devices/devices-list.tsx +361 -0
- package/src/screens/devices/index.ts +1 -0
- package/src/screens/groups/group-details.tsx +8 -142
- package/src/screens/groups/group-invite-panel.tsx +2 -7
- package/src/screens/groups/group-list.test.tsx +44 -0
- package/src/screens/groups/group-list.tsx +227 -288
- package/src/screens/groups/group-members.tsx +3 -2
- package/src/screens/groups/index.ts +0 -5
- package/src/screens/identity/identity-routes.test.ts +70 -0
- package/src/screens/identity/identity-routes.ts +301 -0
- package/src/screens/identity/identity-screen.test.tsx +138 -0
- package/src/screens/identity/identity-screen.tsx +282 -0
- package/src/screens/identity/index.ts +2 -0
- package/src/screens/identity/invite-contact-panel.tsx +45 -0
- package/src/screens/identity/profile-invite.tsx +93 -0
- package/src/screens/invites/accept-invite.tsx +1 -1
- package/src/screens/invites/activity-view.test.tsx +127 -0
- package/src/screens/invites/activity-view.tsx +162 -0
- package/src/screens/invites/index.ts +1 -4
- package/src/screens/invites/invite-capture.ts +0 -3
- package/src/screens/invites/invite-classify.test.ts +110 -0
- package/src/screens/invites/invites-ipc.ts +6 -55
- package/src/screens/invites/pending-invites-list.tsx +85 -61
- package/src/screens/network-viewer/network-viewer-ipc.ts +8 -82
- package/src/screens/search/global-search.tsx +2 -1
- package/src/screens/settings/settings-page.tsx +53 -126
- package/src/screens/setup-user.tsx +5 -6
- package/src/screens/tools/tool-access-ipc.ts +13 -74
- package/src/screens/welcome-modal.tsx +3 -3
- package/src/system-apps/identity.app.ts +9 -0
- package/src/system-apps/index.ts +3 -12
- package/src/tabs-layout/network-status-indicator.test.tsx +22 -0
- package/src/tabs-layout/network-status-indicator.tsx +31 -0
- package/src/tabs-layout/tabs-layout.tsx +7 -1
- package/src/tabs-layout/tabs-state.test.ts +152 -0
- package/src/tabs-layout/tabs-state.ts +300 -83
- package/dist/screens/invites/invites-screen.d.ts +0 -9
- package/dist/screens/invites/invites-screen.js +0 -36
- package/dist/system-apps/account.app.d.ts +0 -2
- package/dist/system-apps/account.app.js +0 -8
- package/dist/system-apps/contacts.app.d.ts +0 -2
- package/dist/system-apps/contacts.app.js +0 -9
- package/dist/system-apps/groups.app.d.ts +0 -2
- package/dist/system-apps/groups.app.js +0 -9
- package/dist/system-apps/invites.app.d.ts +0 -2
- package/dist/system-apps/invites.app.js +0 -9
- package/src/screens/invites/invites-screen.tsx +0 -78
- package/src/system-apps/account.app.ts +0 -7
- package/src/system-apps/contacts.app.ts +0 -8
- package/src/system-apps/groups.app.ts +0 -8
- package/src/system-apps/invites.app.ts +0 -8
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { IInvite, InviteStatus } from "@peers-app/peers-sdk";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { LoadingIndicator } from "../../components/loading-indicator";
|
|
4
|
+
import {
|
|
5
|
+
IDENTITY_ACCEPT_PATH,
|
|
6
|
+
type IdentityInviteType,
|
|
7
|
+
identityActivityFilterPath,
|
|
8
|
+
} from "../identity/identity-routes";
|
|
9
|
+
import { classifyInvite, InviteRow, useInviteRows } from "./pending-invites-list";
|
|
10
|
+
|
|
11
|
+
/** Statuses shown in Activity's History view. */
|
|
12
|
+
const TERMINAL_STATUSES: InviteStatus[] = ["accepted", "declined", "expired", "revoked"];
|
|
13
|
+
|
|
14
|
+
/** Props for {@link ActivityView}. */
|
|
15
|
+
export interface ActivityViewProps {
|
|
16
|
+
/** Optional type and group limit, taken from the route query. */
|
|
17
|
+
filter?: {
|
|
18
|
+
type?: IdentityInviteType;
|
|
19
|
+
groupId?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Unified invite inbox.
|
|
25
|
+
*
|
|
26
|
+
* Pending rows are split into decisions the user can make and items waiting
|
|
27
|
+
* on someone else. History is a separate view, not a disclosure on each list.
|
|
28
|
+
*/
|
|
29
|
+
export function ActivityView(props: ActivityViewProps) {
|
|
30
|
+
const [view, setView] = useState<"pending" | "history">("pending");
|
|
31
|
+
const filter = props.filter;
|
|
32
|
+
const query = useInviteRows({
|
|
33
|
+
type: filter?.type,
|
|
34
|
+
groupId: filter?.groupId,
|
|
35
|
+
statuses: view === "history" ? TERMINAL_STATUSES : ["pending"],
|
|
36
|
+
});
|
|
37
|
+
const rows = query?.rows ?? [];
|
|
38
|
+
const attention = rows.filter((row) => classifyInvite(row) === "attention");
|
|
39
|
+
const waiting = rows.filter((row) => classifyInvite(row) === "waiting");
|
|
40
|
+
const filtered = Boolean(filter?.type || filter?.groupId);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div>
|
|
44
|
+
<div className="d-flex justify-content-between align-items-center gap-2 flex-wrap mb-3">
|
|
45
|
+
<h4 className="mb-0">
|
|
46
|
+
<i className="bi bi-inbox me-2" />
|
|
47
|
+
Activity
|
|
48
|
+
</h4>
|
|
49
|
+
<div className="d-flex gap-2">
|
|
50
|
+
<div className="btn-group btn-group-sm" role="group" aria-label="Invite view">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
className={`btn ${view === "pending" ? "btn-primary" : "btn-outline-primary"}`}
|
|
54
|
+
onClick={() => setView("pending")}
|
|
55
|
+
>
|
|
56
|
+
Pending
|
|
57
|
+
</button>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={`btn ${view === "history" ? "btn-primary" : "btn-outline-primary"}`}
|
|
61
|
+
onClick={() => setView("history")}
|
|
62
|
+
>
|
|
63
|
+
History
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<a href={`#${IDENTITY_ACCEPT_PATH}`} className="btn btn-outline-primary btn-sm">
|
|
67
|
+
<i className="bi-qr-code-scan me-1" />
|
|
68
|
+
Enter or scan an invite
|
|
69
|
+
</a>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{filtered ? (
|
|
74
|
+
<div className="alert alert-secondary py-2 d-flex justify-content-between align-items-center">
|
|
75
|
+
<span>{filterLabel(filter?.type, filter?.groupId)}</span>
|
|
76
|
+
<a href="#identity/activity">Show all</a>
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{!query ? (
|
|
81
|
+
<LoadingIndicator />
|
|
82
|
+
) : view === "history" ? (
|
|
83
|
+
<InviteBucket title="History" rows={rows} empty="No invite history." />
|
|
84
|
+
) : (
|
|
85
|
+
<>
|
|
86
|
+
<InviteBucket
|
|
87
|
+
title="Needs your attention"
|
|
88
|
+
rows={attention}
|
|
89
|
+
empty={
|
|
90
|
+
waiting.length === 0 ? "No invites need attention." : "Nothing needs a decision."
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
<InviteBucket
|
|
94
|
+
title="Waiting on others"
|
|
95
|
+
rows={waiting}
|
|
96
|
+
empty={attention.length === 0 ? "" : "Nothing is waiting on someone else."}
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compact link from a resource screen to the matching Activity filter.
|
|
106
|
+
* Renders nothing when that filter has no pending rows.
|
|
107
|
+
*/
|
|
108
|
+
export function PendingWorkLink(props: { type: IdentityInviteType; groupId?: string }) {
|
|
109
|
+
const query = useInviteRows({
|
|
110
|
+
type: props.type,
|
|
111
|
+
groupId: props.groupId,
|
|
112
|
+
statuses: ["pending"],
|
|
113
|
+
});
|
|
114
|
+
if (!query || query.rows.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const noun = props.type === "contact" ? "contact" : props.type === "group" ? "group" : "device";
|
|
118
|
+
const count = query.rows.length;
|
|
119
|
+
return (
|
|
120
|
+
<a
|
|
121
|
+
href={`#${identityActivityFilterPath(props.type, props.groupId)}`}
|
|
122
|
+
className="alert alert-info py-2 px-3 d-flex justify-content-between align-items-center text-decoration-none"
|
|
123
|
+
>
|
|
124
|
+
<span>
|
|
125
|
+
{count} pending {noun} invite{count === 1 ? "" : "s"}
|
|
126
|
+
</span>
|
|
127
|
+
<span>View in Activity</span>
|
|
128
|
+
</a>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function InviteBucket(props: { title: string; rows: IInvite[]; empty: string }) {
|
|
133
|
+
if (props.rows.length === 0 && !props.empty) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return (
|
|
137
|
+
<section className="mb-4">
|
|
138
|
+
<h6>{props.title}</h6>
|
|
139
|
+
{props.rows.length === 0 ? (
|
|
140
|
+
<p className="text-muted small mb-0">{props.empty}</p>
|
|
141
|
+
) : (
|
|
142
|
+
<div className="list-group">
|
|
143
|
+
{props.rows.map((row) => (
|
|
144
|
+
<InviteRow key={row.inviteId} row={row} />
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</section>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function filterLabel(type: IdentityInviteType | undefined, groupId: string | undefined): string {
|
|
153
|
+
const kind =
|
|
154
|
+
type === "contact" ? "contact" : type === "group" ? "group" : type === "device" ? "device" : "";
|
|
155
|
+
if (groupId) {
|
|
156
|
+
return "Showing invites for this group.";
|
|
157
|
+
}
|
|
158
|
+
if (kind) {
|
|
159
|
+
return `Showing ${kind} invites.`;
|
|
160
|
+
}
|
|
161
|
+
return "Showing a filtered list.";
|
|
162
|
+
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
// Import the screen so it registers its routes.
|
|
2
|
-
import "./invites-screen";
|
|
3
|
-
|
|
4
1
|
export * from "./accept-invite";
|
|
2
|
+
export * from "./activity-view";
|
|
5
3
|
export * from "./invite-capture";
|
|
6
4
|
export * from "./invite-share";
|
|
7
5
|
export * from "./invites-ipc";
|
|
8
|
-
export * from "./invites-screen";
|
|
9
6
|
export * from "./pending-invites-list";
|
|
@@ -11,9 +11,6 @@ import { extractInviteTokenText, observable } from "@peers-app/peers-sdk";
|
|
|
11
11
|
/** sessionStorage key for a captured token awaiting the Accept screen. */
|
|
12
12
|
export const PENDING_INVITE_STORAGE_KEY = "peers.pendingInviteToken";
|
|
13
13
|
|
|
14
|
-
/** Route of the Accept-invite screen. */
|
|
15
|
-
export const ACCEPT_INVITE_PATH = "invites/accept";
|
|
16
|
-
|
|
17
14
|
/** Fires whenever a new token is captured, so a mounted app can navigate immediately. */
|
|
18
15
|
export const inviteCaptured = observable<string | undefined>(undefined);
|
|
19
16
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { IInvite } from "@peers-app/peers-sdk";
|
|
2
|
+
import { actionsForInvite, classifyInvite } from "./pending-invites-list";
|
|
3
|
+
|
|
4
|
+
function invite(
|
|
5
|
+
partial: Partial<IInvite> & Pick<IInvite, "inviteId" | "direction" | "type" | "mode">,
|
|
6
|
+
): IInvite {
|
|
7
|
+
return {
|
|
8
|
+
status: "pending",
|
|
9
|
+
epoch: 1,
|
|
10
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
11
|
+
expiresAt: new Date("2026-02-01T00:00:00Z"),
|
|
12
|
+
...partial,
|
|
13
|
+
} as IInvite;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("classifyInvite", () => {
|
|
17
|
+
const counterpart = {
|
|
18
|
+
userId: "abcdefghijklmnopqrstuvwxy",
|
|
19
|
+
publicKey: "pk",
|
|
20
|
+
publicBoxKey: "box",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
it("puts received decisions in attention", () => {
|
|
24
|
+
const profileRequest = invite({
|
|
25
|
+
inviteId: "1",
|
|
26
|
+
direction: "in",
|
|
27
|
+
type: "contact",
|
|
28
|
+
mode: "profile",
|
|
29
|
+
counterpart,
|
|
30
|
+
parentInviteId: "profileinvite0000000000001",
|
|
31
|
+
});
|
|
32
|
+
const directGroupInvite = invite({
|
|
33
|
+
inviteId: "2",
|
|
34
|
+
direction: "in",
|
|
35
|
+
type: "group",
|
|
36
|
+
mode: "direct",
|
|
37
|
+
counterpart,
|
|
38
|
+
});
|
|
39
|
+
const joinRequest = invite({
|
|
40
|
+
inviteId: "3",
|
|
41
|
+
direction: "in",
|
|
42
|
+
type: "group",
|
|
43
|
+
mode: "approve",
|
|
44
|
+
counterpart,
|
|
45
|
+
parentInviteId: "groupinvite00000000000001",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(classifyInvite(profileRequest)).toBe("attention");
|
|
49
|
+
expect(actionsForInvite(profileRequest)).toEqual(["accept", "decline"]);
|
|
50
|
+
expect(classifyInvite(directGroupInvite)).toBe("attention");
|
|
51
|
+
expect(actionsForInvite(directGroupInvite)).toEqual(["accept", "decline"]);
|
|
52
|
+
expect(classifyInvite(joinRequest)).toBe("attention");
|
|
53
|
+
expect(actionsForInvite(joinRequest)).toEqual(["approve", "deny"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps locally initiated requests and unsupported device rows waiting", () => {
|
|
57
|
+
const contactRequest = invite({
|
|
58
|
+
inviteId: "4",
|
|
59
|
+
direction: "in",
|
|
60
|
+
type: "contact",
|
|
61
|
+
mode: "profile",
|
|
62
|
+
counterpart,
|
|
63
|
+
recipientUserId: counterpart.userId,
|
|
64
|
+
});
|
|
65
|
+
const groupRequest = invite({
|
|
66
|
+
inviteId: "5",
|
|
67
|
+
direction: "in",
|
|
68
|
+
type: "group",
|
|
69
|
+
mode: "approve",
|
|
70
|
+
counterpart,
|
|
71
|
+
recipientUserId: counterpart.userId,
|
|
72
|
+
});
|
|
73
|
+
const outbound = invite({
|
|
74
|
+
inviteId: "6",
|
|
75
|
+
direction: "out",
|
|
76
|
+
type: "group",
|
|
77
|
+
mode: "open",
|
|
78
|
+
});
|
|
79
|
+
const device = invite({
|
|
80
|
+
inviteId: "7",
|
|
81
|
+
direction: "in",
|
|
82
|
+
type: "device",
|
|
83
|
+
mode: "direct",
|
|
84
|
+
counterpart,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(classifyInvite(contactRequest)).toBe("waiting");
|
|
88
|
+
expect(actionsForInvite(contactRequest)).toEqual([]);
|
|
89
|
+
expect(classifyInvite(groupRequest)).toBe("waiting");
|
|
90
|
+
expect(actionsForInvite(groupRequest)).toEqual([]);
|
|
91
|
+
expect(classifyInvite(outbound)).toBe("waiting");
|
|
92
|
+
expect(actionsForInvite(outbound)).toEqual(["revoke"]);
|
|
93
|
+
expect(classifyInvite(device)).toBe("waiting");
|
|
94
|
+
expect(actionsForInvite(device)).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("puts terminal rows in history", () => {
|
|
98
|
+
expect(
|
|
99
|
+
classifyInvite(
|
|
100
|
+
invite({
|
|
101
|
+
inviteId: "8",
|
|
102
|
+
direction: "in",
|
|
103
|
+
type: "contact",
|
|
104
|
+
mode: "contact",
|
|
105
|
+
status: "accepted",
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
).toBe("history");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -1,71 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createContractConsumer,
|
|
3
|
-
getHostContractTransport,
|
|
4
2
|
getInvitesContractDefinition,
|
|
5
3
|
getUserContext,
|
|
6
|
-
|
|
4
|
+
hostContractConsumer,
|
|
7
5
|
type IInvitesConsumer,
|
|
8
|
-
subscribeHostContractTransport,
|
|
9
|
-
waitForHostContractTransport,
|
|
10
6
|
} from "@peers-app/peers-sdk";
|
|
11
7
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
let pending: Promise<IInvitesConsumer> | undefined;
|
|
16
|
-
|
|
17
|
-
subscribeHostContractTransport(
|
|
18
|
-
(transport) => {
|
|
19
|
-
if (cached && cached.transport !== transport) {
|
|
20
|
-
cached.consumer.dispose();
|
|
21
|
-
cached = undefined;
|
|
22
|
-
pending = undefined;
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
{ emitCurrent: false },
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
async function load(transport: IContractTransport): Promise<IInvitesConsumer> {
|
|
29
|
-
const userContext = await getUserContext();
|
|
30
|
-
const consumer = createContractConsumer(getInvitesContractDefinition(), transport, {
|
|
31
|
-
dataContextId: userContext.userId,
|
|
32
|
-
}) as unknown as IInvitesConsumer;
|
|
33
|
-
try {
|
|
34
|
-
await consumer.loadingPromise;
|
|
35
|
-
if (getHostContractTransport() !== transport) {
|
|
36
|
-
consumer.dispose();
|
|
37
|
-
return getInvitesConsumer();
|
|
38
|
-
}
|
|
39
|
-
cached = { transport, consumer };
|
|
40
|
-
return consumer;
|
|
41
|
-
} catch (error) {
|
|
42
|
-
consumer.dispose();
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
8
|
+
const invites = hostContractConsumer<IInvitesConsumer>(getInvitesContractDefinition(), {
|
|
9
|
+
dataContextId: async () => (await getUserContext()).userId,
|
|
10
|
+
});
|
|
46
11
|
|
|
47
12
|
/**
|
|
48
13
|
* Return the typed local-host consumer for the System Invites contract.
|
|
49
14
|
*
|
|
50
15
|
* The consumer is cached per host transport and rebuilt if the transport is replaced
|
|
51
|
-
* (
|
|
16
|
+
* (for example after a socket reconnect).
|
|
52
17
|
*/
|
|
53
18
|
export async function getInvitesConsumer(): Promise<IInvitesConsumer> {
|
|
54
|
-
|
|
55
|
-
getHostContractTransport() ??
|
|
56
|
-
(await waitForHostContractTransport({ timeoutMs: HOST_TRANSPORT_TIMEOUT_MS }));
|
|
57
|
-
if (cached?.transport === transport) {
|
|
58
|
-
return cached.consumer;
|
|
59
|
-
}
|
|
60
|
-
if (pending) {
|
|
61
|
-
return pending;
|
|
62
|
-
}
|
|
63
|
-
pending = load(transport);
|
|
64
|
-
try {
|
|
65
|
-
return await pending;
|
|
66
|
-
} finally {
|
|
67
|
-
pending = undefined;
|
|
68
|
-
}
|
|
19
|
+
return invites.get();
|
|
69
20
|
}
|
|
70
21
|
|
|
71
22
|
/** Shorthand for `(await getInvitesConsumer()).tools`. */
|
|
@@ -8,26 +8,26 @@ import {
|
|
|
8
8
|
import { useCallback, useEffect, useState } from "react";
|
|
9
9
|
import { inviteTools } from "./invites-ipc";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
export interface
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/** Statuses to include; defaults to pending only. */
|
|
18
|
-
statuses?: InviteStatus[];
|
|
19
|
-
/** Hide the section entirely when there is nothing to show. */
|
|
20
|
-
hideWhenEmpty?: boolean;
|
|
21
|
-
title?: string;
|
|
11
|
+
/** Filtered invite rows plus whether any non-pending row exists for the same type and group. */
|
|
12
|
+
export interface IInviteRowQuery {
|
|
13
|
+
/** Rows matching the status filter, newest first. */
|
|
14
|
+
rows: IInvite[];
|
|
15
|
+
/** True when at least one matching invite is not pending. */
|
|
16
|
+
hasHistory: boolean;
|
|
22
17
|
}
|
|
23
18
|
|
|
24
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Subscribe to the personal `Invites` table.
|
|
21
|
+
*
|
|
22
|
+
* @param filter Type, group, and statuses to include. Statuses default to pending.
|
|
23
|
+
* @returns Matching rows and whether any non-pending row exists, or `undefined` while loading.
|
|
24
|
+
*/
|
|
25
25
|
export function useInviteRows(filter: {
|
|
26
26
|
type?: IInvite["type"];
|
|
27
27
|
groupId?: string;
|
|
28
28
|
statuses?: InviteStatus[];
|
|
29
|
-
}):
|
|
30
|
-
const [
|
|
29
|
+
}): IInviteRowQuery | undefined {
|
|
30
|
+
const [query, setQuery] = useState<IInviteRowQuery | undefined>(undefined);
|
|
31
31
|
const statuses = filter.statuses ?? ["pending"];
|
|
32
32
|
const statusKey = statuses.join(",");
|
|
33
33
|
|
|
@@ -39,11 +39,12 @@ export function useInviteRows(filter: {
|
|
|
39
39
|
...(filter.groupId ? { groupId: filter.groupId } : {}),
|
|
40
40
|
});
|
|
41
41
|
const wanted = new Set(statusKey.split(","));
|
|
42
|
-
|
|
43
|
-
all
|
|
42
|
+
setQuery({
|
|
43
|
+
rows: all
|
|
44
44
|
.filter((row) => wanted.has(row.status))
|
|
45
45
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
|
46
|
-
|
|
46
|
+
hasHistory: all.some((row) => row.status !== "pending"),
|
|
47
|
+
});
|
|
47
48
|
}, [filter.type, filter.groupId, statusKey]);
|
|
48
49
|
|
|
49
50
|
useEffect(() => {
|
|
@@ -65,7 +66,7 @@ export function useInviteRows(filter: {
|
|
|
65
66
|
};
|
|
66
67
|
}, [refresh]);
|
|
67
68
|
|
|
68
|
-
return
|
|
69
|
+
return query;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function counterpartLabel(row: IInvite): string {
|
|
@@ -77,8 +78,20 @@ export function describeInviteRow(row: IInvite): { title: string; detail: string
|
|
|
77
78
|
const who = counterpartLabel(row);
|
|
78
79
|
const role = row.role !== undefined ? getRoleLabel(row.role) : undefined;
|
|
79
80
|
const group = row.groupName ?? "a group";
|
|
81
|
+
if (row.type === "device") {
|
|
82
|
+
return {
|
|
83
|
+
title: row.direction === "in" ? `Device request from ${who}` : `Device invite for ${who}`,
|
|
84
|
+
detail: "Device admission actions are not available yet.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
80
87
|
if (row.type === "contact") {
|
|
81
88
|
if (row.direction === "in") {
|
|
89
|
+
if (row.recipientUserId) {
|
|
90
|
+
return {
|
|
91
|
+
title: `Connection request to ${who}`,
|
|
92
|
+
detail: "Waiting for them to confirm.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
82
95
|
return { title: `${who} wants to connect`, detail: "Accept to add them as a contact." };
|
|
83
96
|
}
|
|
84
97
|
if (row.counterpart) {
|
|
@@ -90,13 +103,19 @@ export function describeInviteRow(row: IInvite): { title: string; detail: string
|
|
|
90
103
|
};
|
|
91
104
|
}
|
|
92
105
|
if (row.direction === "in") {
|
|
106
|
+
if (row.recipientUserId) {
|
|
107
|
+
return { title: `Request to join ${group}`, detail: `Waiting for ${who} to approve.` };
|
|
108
|
+
}
|
|
93
109
|
if (row.mode === "direct") {
|
|
94
110
|
return {
|
|
95
111
|
title: `${who} invited you to ${group}`,
|
|
96
112
|
detail: role ? `You would join as ${role}.` : "",
|
|
97
113
|
};
|
|
98
114
|
}
|
|
99
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
title: `${who} wants to join ${group}`,
|
|
117
|
+
detail: role ? `Requested as ${role}. Approve or deny.` : "Approve or deny.",
|
|
118
|
+
};
|
|
100
119
|
}
|
|
101
120
|
if (row.mode === "direct") {
|
|
102
121
|
return { title: `Invited ${who} to ${group}`, detail: "Waiting for them to accept." };
|
|
@@ -113,26 +132,55 @@ export function describeInviteRow(row: IInvite): { title: string; detail: string
|
|
|
113
132
|
};
|
|
114
133
|
}
|
|
115
134
|
|
|
116
|
-
|
|
135
|
+
/** Which Activity bucket a row belongs in. */
|
|
136
|
+
export type InviteBucket = "attention" | "waiting" | "history";
|
|
117
137
|
|
|
118
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Group an invite by what the signed-in user can do with it.
|
|
140
|
+
*
|
|
141
|
+
* Pending rows that need a decision are `attention`. Pending rows waiting on
|
|
142
|
+
* someone else, including links the user issued, are `waiting`.
|
|
143
|
+
*
|
|
144
|
+
* @param row - Invite row from the personal Invites table.
|
|
145
|
+
*/
|
|
146
|
+
export function classifyInvite(row: IInvite): InviteBucket {
|
|
119
147
|
if (row.status !== "pending") {
|
|
148
|
+
return "history";
|
|
149
|
+
}
|
|
150
|
+
return actionsForInvite(row).some((action) => action !== "revoke") ? "attention" : "waiting";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Action that the local user can take on an Activity row. */
|
|
154
|
+
export type InviteRowAction = "accept" | "decline" | "approve" | "deny" | "revoke";
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Return actions that are valid from the local user's perspective.
|
|
158
|
+
*
|
|
159
|
+
* Inbound rows with `recipientUserId` were initiated locally and are waiting
|
|
160
|
+
* for that recipient. Actionable profile requests and join requests omit it.
|
|
161
|
+
*/
|
|
162
|
+
export function actionsForInvite(row: IInvite): InviteRowAction[] {
|
|
163
|
+
if (row.status !== "pending" || row.type === "device") {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
if (row.direction === "out") {
|
|
167
|
+
return ["revoke"];
|
|
168
|
+
}
|
|
169
|
+
if (row.recipientUserId) {
|
|
120
170
|
return [];
|
|
121
171
|
}
|
|
122
172
|
if (row.direction === "in") {
|
|
123
|
-
// Inbound rows I can act on: contact requests and direct group invites.
|
|
124
173
|
if (row.type === "contact" || row.mode === "direct") {
|
|
125
174
|
return ["accept", "decline"];
|
|
126
175
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return ["approve", "deny"];
|
|
176
|
+
if (row.type === "group" && row.mode === "approve" && row.counterpart) {
|
|
177
|
+
return ["approve", "deny"];
|
|
178
|
+
}
|
|
131
179
|
}
|
|
132
|
-
return [
|
|
180
|
+
return [];
|
|
133
181
|
}
|
|
134
182
|
|
|
135
|
-
const ACTION_LABELS: Record<
|
|
183
|
+
const ACTION_LABELS: Record<InviteRowAction, { text: string; className: string }> = {
|
|
136
184
|
accept: { text: "Accept", className: "btn-primary" },
|
|
137
185
|
decline: { text: "Decline", className: "btn-outline-secondary" },
|
|
138
186
|
approve: { text: "Approve", className: "btn-primary" },
|
|
@@ -140,12 +188,13 @@ const ACTION_LABELS: Record<RowAction, { text: string; className: string }> = {
|
|
|
140
188
|
revoke: { text: "Revoke", className: "btn-outline-danger" },
|
|
141
189
|
};
|
|
142
190
|
|
|
143
|
-
|
|
144
|
-
|
|
191
|
+
/** One invite row and the actions its direction and status allow. */
|
|
192
|
+
export function InviteRow({ row }: { row: IInvite }) {
|
|
193
|
+
const [busy, setBusy] = useState<InviteRowAction | undefined>(undefined);
|
|
145
194
|
const [error, setError] = useState("");
|
|
146
195
|
const { title, detail } = describeInviteRow(row);
|
|
147
196
|
|
|
148
|
-
const run = async (action:
|
|
197
|
+
const run = async (action: InviteRowAction) => {
|
|
149
198
|
setBusy(action);
|
|
150
199
|
setError("");
|
|
151
200
|
try {
|
|
@@ -167,7 +216,10 @@ function InviteRow({ row }: { row: IInvite }) {
|
|
|
167
216
|
<div className="list-group-item">
|
|
168
217
|
<div className="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
|
169
218
|
<div className="flex-grow-1">
|
|
170
|
-
<div className="fw-semibold">
|
|
219
|
+
<div className="fw-semibold">
|
|
220
|
+
<span className="badge text-bg-light border me-2 text-capitalize">{row.type}</span>
|
|
221
|
+
{title}
|
|
222
|
+
</div>
|
|
171
223
|
{detail ? <small className="text-muted d-block">{detail}</small> : null}
|
|
172
224
|
{row.deliveryState === "queued" ? (
|
|
173
225
|
<small className="text-warning d-block">Queued until they are reachable</small>
|
|
@@ -177,7 +229,7 @@ function InviteRow({ row }: { row: IInvite }) {
|
|
|
177
229
|
) : null}
|
|
178
230
|
</div>
|
|
179
231
|
<div className="d-flex gap-1">
|
|
180
|
-
{
|
|
232
|
+
{actionsForInvite(row).map((action) => (
|
|
181
233
|
<button
|
|
182
234
|
key={action}
|
|
183
235
|
type="button"
|
|
@@ -194,31 +246,3 @@ function InviteRow({ row }: { row: IInvite }) {
|
|
|
194
246
|
</div>
|
|
195
247
|
);
|
|
196
248
|
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Live list of invite rows with the actions each one supports. Subscribes to the `Invites`
|
|
200
|
-
* table directly, so it updates when replies arrive on any of the user's devices.
|
|
201
|
-
*/
|
|
202
|
-
export function PendingInvitesList(props: PendingInvitesListProps) {
|
|
203
|
-
const rows = useInviteRows(props);
|
|
204
|
-
if (!rows) {
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
207
|
-
if (rows.length === 0 && props.hideWhenEmpty) {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
return (
|
|
211
|
-
<section className="mb-3">
|
|
212
|
-
{props.title ? <h6 className="mb-2">{props.title}</h6> : null}
|
|
213
|
-
{rows.length === 0 ? (
|
|
214
|
-
<p className="text-muted small mb-0">No pending invites.</p>
|
|
215
|
-
) : (
|
|
216
|
-
<div className="list-group">
|
|
217
|
-
{rows.map((row) => (
|
|
218
|
-
<InviteRow key={row.inviteId} row={row} />
|
|
219
|
-
))}
|
|
220
|
-
</div>
|
|
221
|
-
)}
|
|
222
|
-
</section>
|
|
223
|
-
);
|
|
224
|
-
}
|