@peers-app/peers-ui 0.25.3 → 0.25.5

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.
@@ -21,14 +21,17 @@ function PreviewCard(props) {
21
21
  function ResultPanel(props) {
22
22
  const { result, preview } = props;
23
23
  const accepted = result.status === "accepted";
24
+ const openGroup = preview.type === "group" && preview.group?.mode === "open";
24
25
  const message = accepted
25
26
  ? preview.type === "group"
26
27
  ? `You joined ${preview.group?.name}.`
27
28
  : "Contact added."
28
29
  : preview.type === "group"
29
- ? "Join request sent. You will be added once it is approved."
30
+ ? openGroup
31
+ ? `Finishing your automatic join to ${preview.group?.name}.`
32
+ : "Join request sent. You will be added once it is approved."
30
33
  : "Request sent. They will see it and confirm.";
31
- return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsxs)("output", { className: `alert d-block ${accepted ? "alert-success" : "alert-info"}`, children: [message, result.deliveryState === "queued" ? ((0, jsx_runtime_1.jsx)("div", { className: "small mt-1", children: "Queued until the other person is reachable." })) : null] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [accepted && result.groupId ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: () => (0, globals_1.mainContentPath)(`identity/groups/${result.groupId}`), children: "Open group" })) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onDone, children: "Done" })] })] }));
34
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsxs)("output", { className: `alert d-block ${accepted ? "alert-success" : "alert-info"}`, children: [message, result.deliveryState === "queued" ? ((0, jsx_runtime_1.jsx)("div", { className: "small mt-1", children: "Waiting for a route; Peers will retry automatically." })) : null] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [accepted && result.groupId ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: () => (0, globals_1.mainContentPath)(`identity/groups/${result.groupId}`), children: "Open group" })) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onDone, children: "Done" })] })] }));
32
35
  }
33
36
  /**
34
37
  * Paste or scan an invite, review what it grants, then accept it. Also consumes a token
@@ -73,6 +76,45 @@ function AcceptInvite(props) {
73
76
  });
74
77
  return () => sub.dispose();
75
78
  }, [props.initialToken, runPreview]);
79
+ const resultInviteId = result?.inviteId;
80
+ const resultStatus = result?.status;
81
+ (0, react_1.useEffect)(() => {
82
+ if (!resultInviteId || resultStatus !== "pending") {
83
+ return;
84
+ }
85
+ let disposed = false;
86
+ let unsubscribe;
87
+ void (0, peers_sdk_1.getUserContext)().then(async (userContext) => {
88
+ if (disposed) {
89
+ return;
90
+ }
91
+ const table = (0, peers_sdk_1.Invites)(userContext.userDataContext);
92
+ const refresh = async () => {
93
+ const row = await table.get(resultInviteId);
94
+ if (!disposed && row) {
95
+ setResult((current) => current?.inviteId === resultInviteId
96
+ ? {
97
+ ...current,
98
+ status: row.status,
99
+ deliveryState: row.deliveryState,
100
+ groupId: row.groupId ?? current.groupId,
101
+ }
102
+ : current);
103
+ }
104
+ };
105
+ const subscription = table.dataChanged.subscribe((event) => {
106
+ if (event.dataObject.inviteId === resultInviteId) {
107
+ void refresh();
108
+ }
109
+ });
110
+ unsubscribe = () => subscription.unsubscribe();
111
+ await refresh();
112
+ });
113
+ return () => {
114
+ disposed = true;
115
+ unsubscribe?.();
116
+ };
117
+ }, [resultInviteId, resultStatus]);
76
118
  const accept = async () => {
77
119
  setError("");
78
120
  setBusy(true);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
8
+ const react_1 = require("@testing-library/react");
9
+ const user_event_1 = __importDefault(require("@testing-library/user-event"));
10
+ const accept_invite_1 = require("./accept-invite");
11
+ const invites_ipc_1 = require("./invites-ipc");
12
+ jest.mock("@peers-app/peers-sdk", () => {
13
+ const actual = jest.requireActual("@peers-app/peers-sdk");
14
+ return {
15
+ ...actual,
16
+ getUserContext: jest.fn(actual.getUserContext),
17
+ Invites: jest.fn(),
18
+ };
19
+ });
20
+ jest.mock("./invites-ipc", () => ({
21
+ inviteTools: jest.fn(),
22
+ }));
23
+ jest.mock("../../globals", () => ({
24
+ mainContentPath: jest.fn(),
25
+ }));
26
+ const preview = {
27
+ type: "group",
28
+ inviter: {
29
+ userId: "abcdefghijklmnopqrstuvwxy",
30
+ publicKey: "a".repeat(43),
31
+ publicBoxKey: "b".repeat(43),
32
+ displayName: "Ada",
33
+ },
34
+ expiresAt: "2026-10-01T00:00:00.000Z",
35
+ expired: false,
36
+ knownContact: false,
37
+ group: {
38
+ groupId: "zyxwvutsrqponmlkjihgfedcb",
39
+ name: "Neighbors",
40
+ role: 20,
41
+ mode: "open",
42
+ alreadyMember: false,
43
+ },
44
+ };
45
+ const pendingResult = {
46
+ inviteId: "openjoinrequest0000000001",
47
+ status: "pending",
48
+ deliveryState: "queued",
49
+ groupId: preview.group?.groupId,
50
+ };
51
+ function invite(status, deliveryState) {
52
+ return {
53
+ inviteId: pendingResult.inviteId,
54
+ direction: "in",
55
+ type: "group",
56
+ status,
57
+ mode: "open",
58
+ counterpart: preview.inviter,
59
+ groupId: preview.group?.groupId,
60
+ groupName: preview.group?.name,
61
+ role: preview.group?.role,
62
+ epoch: 0,
63
+ recipientUserId: preview.inviter.userId,
64
+ deliveryState,
65
+ createdAt: new Date("2026-09-22T00:00:00.000Z"),
66
+ expiresAt: new Date(preview.expiresAt),
67
+ };
68
+ }
69
+ describe("AcceptInvite", () => {
70
+ beforeEach(() => {
71
+ jest.clearAllMocks();
72
+ });
73
+ it("shows automatic progress and reacts when the delayed approval arrives", async () => {
74
+ let row = invite("pending", "queued");
75
+ let onDataChanged;
76
+ const table = {
77
+ get: jest.fn(async () => row),
78
+ dataChanged: {
79
+ subscribe: jest.fn((listener) => {
80
+ onDataChanged = listener;
81
+ return { unsubscribe: jest.fn() };
82
+ }),
83
+ },
84
+ };
85
+ peers_sdk_1.getUserContext.mockResolvedValue({ userDataContext: {} });
86
+ peers_sdk_1.Invites.mockReturnValue(table);
87
+ invites_ipc_1.inviteTools.mockResolvedValue({
88
+ previewInvite: jest.fn().mockResolvedValue({ preview }),
89
+ acceptInvite: jest.fn().mockResolvedValue({ result: pendingResult }),
90
+ });
91
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(accept_invite_1.AcceptInvite, { initialToken: "signed-token" }));
92
+ await user_event_1.default.click(await react_1.screen.findByRole("button", { name: "Join" }));
93
+ expect(await react_1.screen.findByText("Finishing your automatic join to Neighbors.")).toBeVisible();
94
+ expect(react_1.screen.getByText("Waiting for a route; Peers will retry automatically.")).toBeVisible();
95
+ expect(react_1.screen.queryByText(/once it is approved/i)).not.toBeInTheDocument();
96
+ row = invite("accepted", "sent");
97
+ await (0, react_1.act)(async () => {
98
+ onDataChanged?.({ dataObject: row });
99
+ });
100
+ await (0, react_1.waitFor)(() => expect(react_1.screen.getByText("You joined Neighbors.")).toBeVisible());
101
+ expect(react_1.screen.getByRole("button", { name: "Open group" })).toBeEnabled();
102
+ });
103
+ });
@@ -95,4 +95,21 @@ describe("classifyInvite", () => {
95
95
  status: "accepted",
96
96
  }))).toBe("history");
97
97
  });
98
+ it("describes an open join as automatic rather than approval-gated", () => {
99
+ const openJoin = invite({
100
+ inviteId: "openjoinrequest0000000001",
101
+ direction: "in",
102
+ type: "group",
103
+ mode: "open",
104
+ counterpart: { ...counterpart, displayName: "Ada" },
105
+ recipientUserId: counterpart.userId,
106
+ groupName: "Neighbors",
107
+ deliveryState: "queued",
108
+ });
109
+ expect((0, pending_invites_list_1.describeInviteRow)(openJoin)).toEqual({
110
+ title: "Joining Neighbors",
111
+ detail: "Completing automatically; no approval is required.",
112
+ });
113
+ expect((0, pending_invites_list_1.classifyInvite)(openJoin)).toBe("waiting");
114
+ });
98
115
  });
@@ -88,6 +88,12 @@ function describeInviteRow(row) {
88
88
  }
89
89
  if (row.direction === "in") {
90
90
  if (row.recipientUserId) {
91
+ if (row.mode === "open") {
92
+ return {
93
+ title: `Joining ${group}`,
94
+ detail: "Completing automatically; no approval is required.",
95
+ };
96
+ }
91
97
  return { title: `Request to join ${group}`, detail: `Waiting for ${who} to approve.` };
92
98
  }
93
99
  if (row.mode === "direct") {
@@ -191,5 +197,5 @@ function InviteRow({ row }) {
191
197
  setBusy(undefined);
192
198
  }
193
199
  };
194
- return ((0, jsx_runtime_1.jsxs)("div", { className: "list-group-item", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", children: [(0, jsx_runtime_1.jsxs)("div", { className: "fw-semibold", children: [(0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-light border me-2 text-capitalize", children: row.type }), title] }), detail ? (0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block", children: detail }) : null, row.deliveryState === "queued" ? ((0, jsx_runtime_1.jsx)("small", { className: "text-warning d-block", children: "Queued until they are reachable" })) : null, row.status !== "pending" ? ((0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block text-capitalize", children: row.status })) : null] }), (0, jsx_runtime_1.jsx)("div", { className: "d-flex gap-1", children: actionsForInvite(row).map((action) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: `btn btn-sm ${ACTION_LABELS[action].className}`, disabled: busy !== undefined, onClick: () => void run(action), children: busy === action ? "…" : ACTION_LABELS[action].text }, action))) })] }), error ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger py-1 px-2 small mt-2 mb-0", children: error }) : null] }));
200
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "list-group-item", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", children: [(0, jsx_runtime_1.jsxs)("div", { className: "fw-semibold", children: [(0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-light border me-2 text-capitalize", children: row.type }), title] }), detail ? (0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block", children: detail }) : null, row.deliveryState === "queued" ? ((0, jsx_runtime_1.jsx)("small", { className: "text-warning d-block", children: "Waiting for a route; Peers will retry automatically" })) : null, row.status !== "pending" ? ((0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block text-capitalize", children: row.status })) : null] }), (0, jsx_runtime_1.jsx)("div", { className: "d-flex gap-1", children: actionsForInvite(row).map((action) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: `btn btn-sm ${ACTION_LABELS[action].className}`, disabled: busy !== undefined, onClick: () => void run(action), children: busy === action ? "…" : ACTION_LABELS[action].text }, action))) })] }), error ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger py-1 px-2 small mt-2 mb-0", children: error }) : null] }));
195
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.25.3",
3
+ "version": "0.25.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.25.3",
31
+ "@peers-app/peers-sdk": "^0.25.5",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-react": "^7.24.1",
40
40
  "@babel/preset-typescript": "^7.27.1",
41
41
  "@biomejs/biome": "2.3.11",
42
- "@peers-app/peers-sdk": "0.25.3",
42
+ "@peers-app/peers-sdk": "0.25.5",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -0,0 +1,115 @@
1
+ import {
2
+ getUserContext,
3
+ type IAcceptInviteResult,
4
+ type IInvite,
5
+ type IInvitePreview,
6
+ Invites,
7
+ } from "@peers-app/peers-sdk";
8
+ import { act, render, screen, waitFor } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import { AcceptInvite } from "./accept-invite";
11
+ import { inviteTools } from "./invites-ipc";
12
+
13
+ jest.mock("@peers-app/peers-sdk", () => {
14
+ const actual = jest.requireActual<typeof import("@peers-app/peers-sdk")>("@peers-app/peers-sdk");
15
+ return {
16
+ ...actual,
17
+ getUserContext: jest.fn(actual.getUserContext),
18
+ Invites: jest.fn(),
19
+ };
20
+ });
21
+
22
+ jest.mock("./invites-ipc", () => ({
23
+ inviteTools: jest.fn(),
24
+ }));
25
+
26
+ jest.mock("../../globals", () => ({
27
+ mainContentPath: jest.fn(),
28
+ }));
29
+
30
+ const preview: IInvitePreview = {
31
+ type: "group",
32
+ inviter: {
33
+ userId: "abcdefghijklmnopqrstuvwxy",
34
+ publicKey: "a".repeat(43),
35
+ publicBoxKey: "b".repeat(43),
36
+ displayName: "Ada",
37
+ },
38
+ expiresAt: "2026-10-01T00:00:00.000Z",
39
+ expired: false,
40
+ knownContact: false,
41
+ group: {
42
+ groupId: "zyxwvutsrqponmlkjihgfedcb",
43
+ name: "Neighbors",
44
+ role: 20,
45
+ mode: "open",
46
+ alreadyMember: false,
47
+ },
48
+ };
49
+
50
+ const pendingResult: IAcceptInviteResult = {
51
+ inviteId: "openjoinrequest0000000001",
52
+ status: "pending",
53
+ deliveryState: "queued",
54
+ groupId: preview.group?.groupId,
55
+ };
56
+
57
+ function invite(status: IInvite["status"], deliveryState: IInvite["deliveryState"]): IInvite {
58
+ return {
59
+ inviteId: pendingResult.inviteId,
60
+ direction: "in",
61
+ type: "group",
62
+ status,
63
+ mode: "open",
64
+ counterpart: preview.inviter,
65
+ groupId: preview.group?.groupId,
66
+ groupName: preview.group?.name,
67
+ role: preview.group?.role,
68
+ epoch: 0,
69
+ recipientUserId: preview.inviter.userId,
70
+ deliveryState,
71
+ createdAt: new Date("2026-09-22T00:00:00.000Z"),
72
+ expiresAt: new Date(preview.expiresAt),
73
+ };
74
+ }
75
+
76
+ describe("AcceptInvite", () => {
77
+ beforeEach(() => {
78
+ jest.clearAllMocks();
79
+ });
80
+
81
+ it("shows automatic progress and reacts when the delayed approval arrives", async () => {
82
+ let row = invite("pending", "queued");
83
+ let onDataChanged: ((event: { dataObject: IInvite }) => void) | undefined;
84
+ const table = {
85
+ get: jest.fn(async () => row),
86
+ dataChanged: {
87
+ subscribe: jest.fn((listener: typeof onDataChanged) => {
88
+ onDataChanged = listener;
89
+ return { unsubscribe: jest.fn() };
90
+ }),
91
+ },
92
+ };
93
+ (getUserContext as jest.Mock).mockResolvedValue({ userDataContext: {} });
94
+ (Invites as jest.Mock).mockReturnValue(table);
95
+ (inviteTools as jest.Mock).mockResolvedValue({
96
+ previewInvite: jest.fn().mockResolvedValue({ preview }),
97
+ acceptInvite: jest.fn().mockResolvedValue({ result: pendingResult }),
98
+ });
99
+
100
+ render(<AcceptInvite initialToken="signed-token" />);
101
+ await userEvent.click(await screen.findByRole("button", { name: "Join" }));
102
+
103
+ expect(await screen.findByText("Finishing your automatic join to Neighbors.")).toBeVisible();
104
+ expect(screen.getByText("Waiting for a route; Peers will retry automatically.")).toBeVisible();
105
+ expect(screen.queryByText(/once it is approved/i)).not.toBeInTheDocument();
106
+
107
+ row = invite("accepted", "sent");
108
+ await act(async () => {
109
+ onDataChanged?.({ dataObject: row });
110
+ });
111
+
112
+ await waitFor(() => expect(screen.getByText("You joined Neighbors.")).toBeVisible());
113
+ expect(screen.getByRole("button", { name: "Open group" })).toBeEnabled();
114
+ });
115
+ });
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  getRoleLabel,
3
+ getUserContext,
3
4
  type IAcceptInviteResult,
4
5
  type IInvitePreview,
6
+ Invites,
5
7
  TrustLevel,
6
8
  } from "@peers-app/peers-sdk";
7
9
  import { useCallback, useEffect, useState } from "react";
@@ -59,19 +61,22 @@ function ResultPanel(props: {
59
61
  }) {
60
62
  const { result, preview } = props;
61
63
  const accepted = result.status === "accepted";
64
+ const openGroup = preview.type === "group" && preview.group?.mode === "open";
62
65
  const message = accepted
63
66
  ? preview.type === "group"
64
67
  ? `You joined ${preview.group?.name}.`
65
68
  : "Contact added."
66
69
  : preview.type === "group"
67
- ? "Join request sent. You will be added once it is approved."
70
+ ? openGroup
71
+ ? `Finishing your automatic join to ${preview.group?.name}.`
72
+ : "Join request sent. You will be added once it is approved."
68
73
  : "Request sent. They will see it and confirm.";
69
74
  return (
70
75
  <div className="text-center">
71
76
  <output className={`alert d-block ${accepted ? "alert-success" : "alert-info"}`}>
72
77
  {message}
73
78
  {result.deliveryState === "queued" ? (
74
- <div className="small mt-1">Queued until the other person is reachable.</div>
79
+ <div className="small mt-1">Waiting for a route; Peers will retry automatically.</div>
75
80
  ) : null}
76
81
  </output>
77
82
  <div className="d-flex gap-2 justify-content-center">
@@ -136,6 +141,48 @@ export function AcceptInvite(props: AcceptInviteProps) {
136
141
  return () => sub.dispose();
137
142
  }, [props.initialToken, runPreview]);
138
143
 
144
+ const resultInviteId = result?.inviteId;
145
+ const resultStatus = result?.status;
146
+ useEffect(() => {
147
+ if (!resultInviteId || resultStatus !== "pending") {
148
+ return;
149
+ }
150
+ let disposed = false;
151
+ let unsubscribe: (() => void) | undefined;
152
+ void getUserContext().then(async (userContext) => {
153
+ if (disposed) {
154
+ return;
155
+ }
156
+ const table = Invites(userContext.userDataContext);
157
+ const refresh = async () => {
158
+ const row = await table.get(resultInviteId);
159
+ if (!disposed && row) {
160
+ setResult((current) =>
161
+ current?.inviteId === resultInviteId
162
+ ? {
163
+ ...current,
164
+ status: row.status,
165
+ deliveryState: row.deliveryState,
166
+ groupId: row.groupId ?? current.groupId,
167
+ }
168
+ : current,
169
+ );
170
+ }
171
+ };
172
+ const subscription = table.dataChanged.subscribe((event) => {
173
+ if (event.dataObject.inviteId === resultInviteId) {
174
+ void refresh();
175
+ }
176
+ });
177
+ unsubscribe = () => subscription.unsubscribe();
178
+ await refresh();
179
+ });
180
+ return () => {
181
+ disposed = true;
182
+ unsubscribe?.();
183
+ };
184
+ }, [resultInviteId, resultStatus]);
185
+
139
186
  const accept = async () => {
140
187
  setError("");
141
188
  setBusy(true);
@@ -1,5 +1,5 @@
1
1
  import type { IInvite } from "@peers-app/peers-sdk";
2
- import { actionsForInvite, classifyInvite } from "./pending-invites-list";
2
+ import { actionsForInvite, classifyInvite, describeInviteRow } from "./pending-invites-list";
3
3
 
4
4
  function invite(
5
5
  partial: Partial<IInvite> & Pick<IInvite, "inviteId" | "direction" | "type" | "mode">,
@@ -107,4 +107,23 @@ describe("classifyInvite", () => {
107
107
  ),
108
108
  ).toBe("history");
109
109
  });
110
+
111
+ it("describes an open join as automatic rather than approval-gated", () => {
112
+ const openJoin = invite({
113
+ inviteId: "openjoinrequest0000000001",
114
+ direction: "in",
115
+ type: "group",
116
+ mode: "open",
117
+ counterpart: { ...counterpart, displayName: "Ada" },
118
+ recipientUserId: counterpart.userId,
119
+ groupName: "Neighbors",
120
+ deliveryState: "queued",
121
+ });
122
+
123
+ expect(describeInviteRow(openJoin)).toEqual({
124
+ title: "Joining Neighbors",
125
+ detail: "Completing automatically; no approval is required.",
126
+ });
127
+ expect(classifyInvite(openJoin)).toBe("waiting");
128
+ });
110
129
  });
@@ -104,6 +104,12 @@ export function describeInviteRow(row: IInvite): { title: string; detail: string
104
104
  }
105
105
  if (row.direction === "in") {
106
106
  if (row.recipientUserId) {
107
+ if (row.mode === "open") {
108
+ return {
109
+ title: `Joining ${group}`,
110
+ detail: "Completing automatically; no approval is required.",
111
+ };
112
+ }
107
113
  return { title: `Request to join ${group}`, detail: `Waiting for ${who} to approve.` };
108
114
  }
109
115
  if (row.mode === "direct") {
@@ -222,7 +228,9 @@ export function InviteRow({ row }: { row: IInvite }) {
222
228
  </div>
223
229
  {detail ? <small className="text-muted d-block">{detail}</small> : null}
224
230
  {row.deliveryState === "queued" ? (
225
- <small className="text-warning d-block">Queued until they are reachable</small>
231
+ <small className="text-warning d-block">
232
+ Waiting for a route; Peers will retry automatically
233
+ </small>
226
234
  ) : null}
227
235
  {row.status !== "pending" ? (
228
236
  <small className="text-muted d-block text-capitalize">{row.status}</small>