@peers-app/peers-ui 0.25.1 → 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.
Files changed (177) hide show
  1. package/README.md +2 -1
  2. package/dist/components/markdown-editor/mention-node.d.ts +4 -0
  3. package/dist/components/markdown-editor/mention-node.js +4 -1
  4. package/dist/components/responsive-section-nav.d.ts +34 -0
  5. package/dist/components/responsive-section-nav.js +19 -0
  6. package/dist/components/responsive-section-nav.test.d.ts +1 -0
  7. package/dist/components/responsive-section-nav.test.js +45 -0
  8. package/dist/components/router.d.ts +2 -3
  9. package/dist/components/router.js +6 -9
  10. package/dist/globals-hash.test.d.ts +1 -0
  11. package/dist/globals-hash.test.js +16 -0
  12. package/dist/globals.d.ts +19 -0
  13. package/dist/globals.js +51 -12
  14. package/dist/hooks/use-host-contract.d.ts +18 -0
  15. package/dist/hooks/use-host-contract.js +53 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/peers-services-registration.d.ts +1 -5
  19. package/dist/peers-services-registration.js +6 -16
  20. package/dist/root-layout/root-layout-host.js +7 -4
  21. package/dist/root-layout/root-layout-host.test.js +3 -6
  22. package/dist/screens/account/account-screen.d.ts +1 -0
  23. package/dist/screens/account/account-screen.js +29 -53
  24. package/dist/screens/account/account-screen.test.d.ts +1 -0
  25. package/dist/screens/account/account-screen.test.js +47 -0
  26. package/dist/screens/contacts/contact-details.js +2 -23
  27. package/dist/screens/contacts/contact-list.d.ts +24 -1
  28. package/dist/screens/contacts/contact-list.js +128 -143
  29. package/dist/screens/contacts/contact-list.test.d.ts +1 -0
  30. package/dist/screens/contacts/contact-list.test.js +48 -0
  31. package/dist/screens/contacts/index.d.ts +0 -2
  32. package/dist/screens/contacts/index.js +0 -3
  33. package/dist/screens/device-pairing/device-pairing.js +17 -16
  34. package/dist/screens/device-pairing/device-pairing.test.js +4 -9
  35. package/dist/screens/device-pairing/identity-ipc.d.ts +11 -0
  36. package/dist/screens/device-pairing/identity-ipc.js +20 -0
  37. package/dist/screens/device-pairing/use-device-pairing-state.d.ts +2 -2
  38. package/dist/screens/device-pairing/use-device-pairing-state.js +7 -25
  39. package/dist/screens/devices/devices-list.d.ts +18 -0
  40. package/dist/screens/devices/devices-list.js +173 -0
  41. package/dist/screens/devices/devices-list.test.d.ts +1 -0
  42. package/dist/screens/devices/devices-list.test.js +53 -0
  43. package/dist/screens/devices/index.d.ts +1 -0
  44. package/dist/screens/devices/index.js +5 -0
  45. package/dist/screens/groups/group-details.d.ts +1 -0
  46. package/dist/screens/groups/group-details.js +4 -63
  47. package/dist/screens/groups/group-invite-panel.js +2 -2
  48. package/dist/screens/groups/group-list.d.ts +18 -1
  49. package/dist/screens/groups/group-list.js +107 -136
  50. package/dist/screens/groups/group-list.test.d.ts +1 -0
  51. package/dist/screens/groups/group-list.test.js +38 -0
  52. package/dist/screens/groups/group-members.d.ts +1 -0
  53. package/dist/screens/groups/group-members.js +2 -1
  54. package/dist/screens/groups/index.d.ts +0 -3
  55. package/dist/screens/groups/index.js +0 -4
  56. package/dist/screens/identity/identity-routes.d.ts +107 -0
  57. package/dist/screens/identity/identity-routes.js +245 -0
  58. package/dist/screens/identity/identity-routes.test.d.ts +1 -0
  59. package/dist/screens/identity/identity-routes.test.js +51 -0
  60. package/dist/screens/identity/identity-screen.d.ts +9 -0
  61. package/dist/screens/identity/identity-screen.js +143 -0
  62. package/dist/screens/identity/identity-screen.test.d.ts +1 -0
  63. package/dist/screens/identity/identity-screen.test.js +103 -0
  64. package/dist/screens/identity/index.d.ts +2 -0
  65. package/dist/screens/identity/index.js +6 -0
  66. package/dist/screens/identity/invite-contact-panel.d.ts +4 -0
  67. package/dist/screens/identity/invite-contact-panel.js +30 -0
  68. package/dist/screens/identity/profile-invite.d.ts +7 -0
  69. package/dist/screens/identity/profile-invite.js +62 -0
  70. package/dist/screens/invites/accept-invite.js +1 -1
  71. package/dist/screens/invites/activity-view.d.ts +24 -0
  72. package/dist/screens/invites/activity-view.js +64 -0
  73. package/dist/screens/invites/activity-view.test.d.ts +1 -0
  74. package/dist/screens/invites/activity-view.test.js +108 -0
  75. package/dist/screens/invites/index.d.ts +1 -2
  76. package/dist/screens/invites/index.js +1 -3
  77. package/dist/screens/invites/invite-capture.d.ts +0 -2
  78. package/dist/screens/invites/invite-capture.js +1 -3
  79. package/dist/screens/invites/invite-classify.test.d.ts +1 -0
  80. package/dist/screens/invites/invite-classify.test.js +98 -0
  81. package/dist/screens/invites/invites-ipc.d.ts +1 -1
  82. package/dist/screens/invites/invites-ipc.js +5 -45
  83. package/dist/screens/invites/pending-invites-list.d.ts +35 -16
  84. package/dist/screens/invites/pending-invites-list.js +69 -30
  85. package/dist/screens/network-viewer/network-viewer-ipc.js +4 -59
  86. package/dist/screens/search/global-search.d.ts +1 -0
  87. package/dist/screens/search/global-search.js +2 -1
  88. package/dist/screens/settings/settings-page.js +25 -58
  89. package/dist/screens/setup-user.js +5 -5
  90. package/dist/screens/tools/tool-access-ipc.d.ts +2 -0
  91. package/dist/screens/tools/tool-access-ipc.js +8 -55
  92. package/dist/screens/welcome-modal.js +3 -2
  93. package/dist/system-apps/identity.app.d.ts +3 -0
  94. package/dist/system-apps/identity.app.js +10 -0
  95. package/dist/system-apps/index.d.ts +1 -4
  96. package/dist/system-apps/index.js +5 -17
  97. package/dist/tabs-layout/network-status-indicator.js +10 -3
  98. package/dist/tabs-layout/network-status-indicator.test.js +15 -0
  99. package/dist/tabs-layout/tabs-layout.js +3 -1
  100. package/dist/tabs-layout/tabs-state.d.ts +79 -1
  101. package/dist/tabs-layout/tabs-state.js +256 -79
  102. package/dist/tabs-layout/tabs-state.test.d.ts +1 -0
  103. package/dist/tabs-layout/tabs-state.test.js +110 -0
  104. package/package.json +3 -3
  105. package/src/components/markdown-editor/mention-node.ts +5 -1
  106. package/src/components/responsive-section-nav.test.tsx +88 -0
  107. package/src/components/responsive-section-nav.tsx +92 -0
  108. package/src/components/router.tsx +6 -9
  109. package/src/globals-hash.test.ts +19 -0
  110. package/src/globals.tsx +53 -13
  111. package/src/hooks/use-host-contract.ts +75 -0
  112. package/src/index.tsx +1 -0
  113. package/src/peers-services-registration.ts +6 -17
  114. package/src/root-layout/root-layout-host.test.tsx +4 -7
  115. package/src/root-layout/root-layout-host.tsx +9 -11
  116. package/src/screens/account/account-screen.test.tsx +61 -0
  117. package/src/screens/account/account-screen.tsx +116 -95
  118. package/src/screens/contacts/contact-details.tsx +4 -26
  119. package/src/screens/contacts/contact-list.test.tsx +59 -0
  120. package/src/screens/contacts/contact-list.tsx +246 -260
  121. package/src/screens/contacts/index.ts +0 -4
  122. package/src/screens/device-pairing/device-pairing.test.tsx +7 -9
  123. package/src/screens/device-pairing/device-pairing.tsx +19 -23
  124. package/src/screens/device-pairing/identity-ipc.ts +25 -0
  125. package/src/screens/device-pairing/use-device-pairing-state.ts +10 -33
  126. package/src/screens/devices/devices-list.test.tsx +68 -0
  127. package/src/screens/devices/devices-list.tsx +361 -0
  128. package/src/screens/devices/index.ts +1 -0
  129. package/src/screens/groups/group-details.tsx +8 -142
  130. package/src/screens/groups/group-invite-panel.tsx +2 -7
  131. package/src/screens/groups/group-list.test.tsx +44 -0
  132. package/src/screens/groups/group-list.tsx +227 -288
  133. package/src/screens/groups/group-members.tsx +3 -2
  134. package/src/screens/groups/index.ts +0 -5
  135. package/src/screens/identity/identity-routes.test.ts +70 -0
  136. package/src/screens/identity/identity-routes.ts +301 -0
  137. package/src/screens/identity/identity-screen.test.tsx +138 -0
  138. package/src/screens/identity/identity-screen.tsx +282 -0
  139. package/src/screens/identity/index.ts +2 -0
  140. package/src/screens/identity/invite-contact-panel.tsx +45 -0
  141. package/src/screens/identity/profile-invite.tsx +93 -0
  142. package/src/screens/invites/accept-invite.tsx +1 -1
  143. package/src/screens/invites/activity-view.test.tsx +127 -0
  144. package/src/screens/invites/activity-view.tsx +162 -0
  145. package/src/screens/invites/index.ts +1 -4
  146. package/src/screens/invites/invite-capture.ts +0 -3
  147. package/src/screens/invites/invite-classify.test.ts +110 -0
  148. package/src/screens/invites/invites-ipc.ts +6 -55
  149. package/src/screens/invites/pending-invites-list.tsx +85 -61
  150. package/src/screens/network-viewer/network-viewer-ipc.ts +8 -82
  151. package/src/screens/search/global-search.tsx +2 -1
  152. package/src/screens/settings/settings-page.tsx +53 -126
  153. package/src/screens/setup-user.tsx +5 -6
  154. package/src/screens/tools/tool-access-ipc.ts +13 -74
  155. package/src/screens/welcome-modal.tsx +3 -3
  156. package/src/system-apps/identity.app.ts +9 -0
  157. package/src/system-apps/index.ts +3 -12
  158. package/src/tabs-layout/network-status-indicator.test.tsx +22 -0
  159. package/src/tabs-layout/network-status-indicator.tsx +31 -0
  160. package/src/tabs-layout/tabs-layout.tsx +7 -1
  161. package/src/tabs-layout/tabs-state.test.ts +152 -0
  162. package/src/tabs-layout/tabs-state.ts +300 -83
  163. package/dist/screens/invites/invites-screen.d.ts +0 -9
  164. package/dist/screens/invites/invites-screen.js +0 -36
  165. package/dist/system-apps/account.app.d.ts +0 -2
  166. package/dist/system-apps/account.app.js +0 -8
  167. package/dist/system-apps/contacts.app.d.ts +0 -2
  168. package/dist/system-apps/contacts.app.js +0 -9
  169. package/dist/system-apps/groups.app.d.ts +0 -2
  170. package/dist/system-apps/groups.app.js +0 -9
  171. package/dist/system-apps/invites.app.d.ts +0 -2
  172. package/dist/system-apps/invites.app.js +0 -9
  173. package/src/screens/invites/invites-screen.tsx +0 -78
  174. package/src/system-apps/account.app.ts +0 -7
  175. package/src/system-apps/contacts.app.ts +0 -8
  176. package/src/system-apps/groups.app.ts +0 -8
  177. 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
- type IContractTransport,
4
+ hostContractConsumer,
7
5
  type IInvitesConsumer,
8
- subscribeHostContractTransport,
9
- waitForHostContractTransport,
10
6
  } from "@peers-app/peers-sdk";
11
7
 
12
- const HOST_TRANSPORT_TIMEOUT_MS = 10_000;
13
-
14
- let cached: { transport: IContractTransport; consumer: IInvitesConsumer } | undefined;
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
- * (e.g. after a socket reconnect).
16
+ * (for example after a socket reconnect).
52
17
  */
53
18
  export async function getInvitesConsumer(): Promise<IInvitesConsumer> {
54
- const transport =
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
- /** Props for {@link PendingInvitesList}. */
12
- export interface PendingInvitesListProps {
13
- /** Only show rows of this type. */
14
- type?: IInvite["type"];
15
- /** Only show rows for this group. */
16
- groupId?: string;
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
- /** Subscribe to the personal `Invites` table and return rows matching the filter, newest first. */
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
- }): IInvite[] | undefined {
30
- const [rows, setRows] = useState<IInvite[] | undefined>(undefined);
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
- setRows(
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 rows;
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 { title: `Joining ${group}`, detail: `Waiting for ${who} to approve.` };
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
- type RowAction = "accept" | "decline" | "approve" | "deny" | "revoke";
135
+ /** Which Activity bucket a row belongs in. */
136
+ export type InviteBucket = "attention" | "waiting" | "history";
117
137
 
118
- function actionsFor(row: IInvite): RowAction[] {
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
- return []; // my own outstanding join request; nothing to do but wait
128
- }
129
- if (row.counterpart && row.type === "group" && row.mode !== "direct") {
130
- return ["approve", "deny"];
176
+ if (row.type === "group" && row.mode === "approve" && row.counterpart) {
177
+ return ["approve", "deny"];
178
+ }
131
179
  }
132
- return ["revoke"];
180
+ return [];
133
181
  }
134
182
 
135
- const ACTION_LABELS: Record<RowAction, { text: string; className: string }> = {
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
- function InviteRow({ row }: { row: IInvite }) {
144
- const [busy, setBusy] = useState<RowAction | undefined>(undefined);
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: RowAction) => {
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">{title}</div>
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
- {actionsFor(row).map((action) => (
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
- }