@peers-app/peers-ui 0.8.4 → 0.8.6

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 (39) hide show
  1. package/dist/components/left-bar.js +1 -3
  2. package/dist/components/router.d.ts +1 -0
  3. package/dist/components/router.js +2 -5
  4. package/dist/hooks.d.ts +3 -2
  5. package/dist/hooks.js +22 -53
  6. package/dist/hooks.test.d.ts +1 -0
  7. package/dist/hooks.test.js +247 -0
  8. package/dist/screens/groups/group-invite-listener.d.ts +15 -0
  9. package/dist/screens/groups/group-invite-listener.js +189 -0
  10. package/dist/screens/groups/group-members.js +3 -1
  11. package/dist/screens/join-group/index.d.ts +2 -0
  12. package/dist/screens/join-group/index.js +19 -0
  13. package/dist/screens/join-group/join-group.d.ts +13 -0
  14. package/dist/screens/join-group/join-group.js +202 -0
  15. package/dist/screens/settings/settings-page.js +95 -29
  16. package/dist/screens/welcome-modal.d.ts +7 -0
  17. package/dist/screens/welcome-modal.js +193 -0
  18. package/dist/setupTests.js +4 -0
  19. package/dist/system-apps/index.d.ts +1 -0
  20. package/dist/system-apps/index.js +5 -1
  21. package/dist/system-apps/join-group.app.d.ts +2 -0
  22. package/dist/system-apps/join-group.app.js +9 -0
  23. package/dist/tabs-layout/tabs-layout.js +14 -3
  24. package/package.json +5 -5
  25. package/src/components/left-bar.tsx +1 -2
  26. package/src/components/router.tsx +2 -5
  27. package/src/hooks.test.tsx +324 -0
  28. package/src/hooks.ts +23 -51
  29. package/src/screens/groups/group-invite-listener.tsx +285 -0
  30. package/src/screens/groups/group-members.tsx +7 -1
  31. package/src/screens/join-group/index.ts +4 -0
  32. package/src/screens/join-group/join-group.tsx +294 -0
  33. package/src/screens/settings/settings-page.tsx +154 -40
  34. package/src/screens/welcome-modal.tsx +258 -0
  35. package/src/setupTests.ts +5 -0
  36. package/src/system-apps/index.ts +3 -0
  37. package/src/system-apps/join-group.app.ts +8 -0
  38. package/src/tabs-layout/tabs-layout.tsx +21 -3
  39. package/tsconfig.json +3 -5
@@ -47,9 +47,7 @@ const LeftBarContent = () => {
47
47
  react_1.default.createElement("i", { className: "bi bi-three-dots-vertical", style: { fontSize: '12pt' } })),
48
48
  react_1.default.createElement("ul", { className: "dropdown-menu dropdown-menu-end dropdown-menu-dark text-small shadow", style: { border: '1px grey solid' }, "aria-labelledby": "dropdownUser1" },
49
49
  react_1.default.createElement("li", null,
50
- react_1.default.createElement("a", { className: "dropdown-item", href: "#profile" }, "Profile")),
51
- react_1.default.createElement("li", null,
52
- react_1.default.createElement("a", { className: "dropdown-item", href: "#settings" }, "Settings"))))),
50
+ react_1.default.createElement("a", { className: "dropdown-item", href: "#settings" }, "Profile & Settings"))))),
53
51
  react_1.default.createElement("hr", { className: 'p-0' }),
54
52
  react_1.default.createElement(MenuSection, { menuItems: [
55
53
  // { text: "Shell", icon: "bi bi-robot" },
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import "../screens/console-logs/console-logs-list";
3
3
  import "../screens/groups";
4
4
  import "../screens/contacts";
5
+ import "../screens/join-group";
5
6
  import "../screens/network-viewer";
6
7
  import "../screens/data-explorer";
7
8
  export declare function Router({ path: providedPath }?: {
@@ -41,7 +41,6 @@ const react_1 = __importDefault(require("react"));
41
41
  const globals = __importStar(require("../globals"));
42
42
  const assistant_details_1 = require("../screens/assistants/assistant-details");
43
43
  const assistant_list_1 = require("../screens/assistants/assistant-list");
44
- const profile_1 = require("../screens/profile");
45
44
  // import { TaskDetails } from "../screens/tasks/task-details";
46
45
  // import { TaskList } from "../screens/tasks/task-list";
47
46
  const tool_details_1 = require("../screens/tools/tool-details");
@@ -73,6 +72,7 @@ const global_search_1 = require("../screens/search/global-search");
73
72
  require("../screens/console-logs/console-logs-list");
74
73
  require("../screens/groups");
75
74
  require("../screens/contacts");
75
+ require("../screens/join-group");
76
76
  require("../screens/network-viewer");
77
77
  require("../screens/data-explorer");
78
78
  function Router({ path: providedPath } = {}) {
@@ -94,12 +94,9 @@ function Router({ path: providedPath } = {}) {
94
94
  if (path === 'search') {
95
95
  return react_1.default.createElement(global_search_1.GlobalSearch, null);
96
96
  }
97
- if (path === 'settings') {
97
+ if (path === 'settings' || path === 'profile') {
98
98
  return react_1.default.createElement(settings_page_1.SettingsPage, null);
99
99
  }
100
- if (path === 'profile') {
101
- return react_1.default.createElement(profile_1.Profile, null);
102
- }
103
100
  if (path === 'threads' || path === '') {
104
101
  return react_1.default.createElement(channel_view_1.ChannelMessages, null);
105
102
  }
package/dist/hooks.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import React from 'react';
2
1
  import { Observable } from "@peers-app/peers-sdk";
2
+ import React from 'react';
3
3
  /**
4
4
  * Use this to subscribe to an observable or computed in a functional component.
5
+ * Uses useSyncExternalStore for proper React 18 concurrent rendering support.
5
6
  * @param sub the observable or computed to subscribe to
6
- * @param deps an array of dependencies to pass to useEffect which will trigger a re-render when any of the dependencies change
7
+ * @param deps an array of dependencies that will cause re-subscription when changed
7
8
  * @returns the current value of the observable or computed and a function to set the value
8
9
  */
9
10
  export declare function useObservable<T>(sub: Observable<T> | T, deps?: React.DependencyList): [T, (value: T) => void];
package/dist/hooks.js CHANGED
@@ -5,46 +5,38 @@ exports.usePromise = usePromise;
5
5
  exports.useObservableState = useObservableState;
6
6
  exports.useSubscription = useSubscription;
7
7
  exports.useOnScreen = useOnScreen;
8
- const react_1 = require("react");
9
- const lodash_1 = require("lodash");
10
8
  const peers_sdk_1 = require("@peers-app/peers-sdk");
9
+ const react_1 = require("react");
11
10
  /**
12
11
  * Use this to subscribe to an observable or computed in a functional component.
12
+ * Uses useSyncExternalStore for proper React 18 concurrent rendering support.
13
13
  * @param sub the observable or computed to subscribe to
14
- * @param deps an array of dependencies to pass to useEffect which will trigger a re-render when any of the dependencies change
14
+ * @param deps an array of dependencies that will cause re-subscription when changed
15
15
  * @returns the current value of the observable or computed and a function to set the value
16
16
  */
17
17
  function useObservable(sub, deps = []) {
18
- const [data, setData] = (0, react_1.useState)(() => (0, peers_sdk_1.unwrapObservable)(sub));
19
- (0, react_1.useEffect)(() => {
18
+ // Create memoized subscribe function that adapts Observable's subscribe API to useSyncExternalStore's expected signature
19
+ const subscribe = (0, react_1.useCallback)((onStoreChange) => {
20
20
  if (!(0, peers_sdk_1.isSubscribable)(sub)) {
21
- return;
22
- }
23
- const subscription = sub.subscribe(() => {
24
- const newData = sub();
25
- // @ts-ignore
26
- if ((0, lodash_1.isArray)(newData) && newData === data) {
27
- // @ts-ignore
28
- setData([...newData]);
29
- }
30
- else {
31
- setData(newData);
32
- }
33
- });
34
- // the data might change _after_ useObservable is called but _before_ the subscription has been created
35
- // this checks for that and updates the state with the new data if necessary
36
- const newData = sub();
37
- if (!(0, lodash_1.isEqual)(data, newData)) {
38
- setData(newData);
21
+ // If not subscribable, return a no-op unsubscribe
22
+ return () => { };
39
23
  }
24
+ const subscription = sub.subscribe(onStoreChange);
40
25
  return () => subscription.dispose();
41
- }, deps);
42
- return [data, newData => {
43
- setData(newData);
44
- if ((0, peers_sdk_1.isSubscribable)(sub)) {
45
- sub(newData);
46
- }
47
- }];
26
+ }, [sub, ...deps]);
27
+ // getSnapshot returns the current value from the observable
28
+ const getSnapshot = (0, react_1.useCallback)(() => {
29
+ return (0, peers_sdk_1.unwrapObservable)(sub);
30
+ }, [sub, ...deps]);
31
+ // Use useSyncExternalStore for concurrent-safe subscription
32
+ const data = (0, react_1.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
33
+ // Create setter that updates both local understanding and the observable
34
+ const setter = (0, react_1.useCallback)((newData) => {
35
+ if ((0, peers_sdk_1.isSubscribable)(sub)) {
36
+ sub(newData);
37
+ }
38
+ }, [sub]);
39
+ return [data, setter];
48
40
  }
49
41
  /**
50
42
  * Use this to easily wait for a promise in a functional component.
@@ -104,26 +96,3 @@ function useOnScreen(ref) {
104
96
  }, []);
105
97
  return isIntersecting;
106
98
  }
107
- // /**
108
- // * This creates an observable that will automatically persist its value between page reloads using localStorage.
109
- // * If localStorage is not available, the observable will not persist its value.
110
- // * @param initialValue the initial value of the observable
111
- // * @param globalName the name to use when storing the value in localStorage
112
- // * @returns the observable that will persist between page reloads
113
- // */
114
- // export function persistentValue<T>(initialValue: T, globalName: string): Observable<T | undefined> {
115
- // let q = observable<T>();
116
- // if (typeof localStorage === 'undefined') {
117
- // return q;
118
- // }
119
- // q.subscribe(newVal => {
120
- // localStorage.setItem(globalName, JSON.stringify(toJSON(newVal)))
121
- // })
122
- // const existing = localStorage.getItem(globalName);
123
- // if (existing) {
124
- // q(fromJSON(JSON.parse(existing)))
125
- // } else {
126
- // q(initialValue);
127
- // }
128
- // return q;
129
- // }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,247 @@
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 react_1 = __importDefault(require("react"));
7
+ const react_2 = require("@testing-library/react");
8
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
9
+ const hooks_1 = require("./hooks");
10
+ // Helper component to test useObservable
11
+ function TestComponent({ obs, testId = 'value' }) {
12
+ const [value, setValue] = (0, hooks_1.useObservable)(obs);
13
+ return (react_1.default.createElement("div", null,
14
+ react_1.default.createElement("span", { "data-testid": testId }, JSON.stringify(value)),
15
+ react_1.default.createElement("button", { "data-testid": "setter", onClick: () => setValue('new-value') }, "Set")));
16
+ }
17
+ // Helper component to test array behavior
18
+ function ArrayTestComponent({ obs }) {
19
+ const [value] = (0, hooks_1.useObservable)(obs);
20
+ return (react_1.default.createElement("div", null,
21
+ react_1.default.createElement("span", { "data-testid": "array-value" }, JSON.stringify(value)),
22
+ react_1.default.createElement("span", { "data-testid": "array-length" }, value.length)));
23
+ }
24
+ // Helper component to test useObservableState
25
+ function ObservableStateTestComponent({ initialValue }) {
26
+ const obs = (0, hooks_1.useObservableState)(initialValue);
27
+ const [value] = (0, hooks_1.useObservable)(obs);
28
+ return (react_1.default.createElement("div", null,
29
+ react_1.default.createElement("span", { "data-testid": "state-value" }, value),
30
+ react_1.default.createElement("button", { "data-testid": "increment", onClick: () => obs(obs() + 1) }, "Increment")));
31
+ }
32
+ describe('useObservable', () => {
33
+ describe('basic functionality', () => {
34
+ it('should return the current value of an observable', () => {
35
+ const obs = (0, peers_sdk_1.observable)('hello');
36
+ (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
37
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('"hello"');
38
+ });
39
+ it('should return the value when passed a plain value (non-observable)', () => {
40
+ (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: "plain-value" }));
41
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('"plain-value"');
42
+ });
43
+ it('should re-render when observable value changes', async () => {
44
+ const obs = (0, peers_sdk_1.observable)('initial');
45
+ (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
46
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('"initial"');
47
+ (0, react_2.act)(() => {
48
+ obs('updated');
49
+ });
50
+ await (0, react_2.waitFor)(() => {
51
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('"updated"');
52
+ });
53
+ });
54
+ it('should update the observable when setter is called', async () => {
55
+ const obs = (0, peers_sdk_1.observable)('initial');
56
+ (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
57
+ (0, react_2.act)(() => {
58
+ react_2.screen.getByTestId('setter').click();
59
+ });
60
+ await (0, react_2.waitFor)(() => {
61
+ expect(obs()).toBe('new-value');
62
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('"new-value"');
63
+ });
64
+ });
65
+ it('should handle undefined values', () => {
66
+ const obs = (0, peers_sdk_1.observable)(undefined);
67
+ (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
68
+ // undefined serializes to empty string in JSON.stringify, but actually shows as nothing
69
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('');
70
+ });
71
+ it('should handle object values', async () => {
72
+ const obs = (0, peers_sdk_1.observable)({ name: 'test', count: 5 });
73
+ (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
74
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('{"name":"test","count":5}');
75
+ (0, react_2.act)(() => {
76
+ obs({ name: 'updated', count: 10 });
77
+ });
78
+ await (0, react_2.waitFor)(() => {
79
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('{"name":"updated","count":10}');
80
+ });
81
+ });
82
+ });
83
+ describe('array handling', () => {
84
+ it('should handle array values', () => {
85
+ const obs = (0, peers_sdk_1.observable)(['a', 'b', 'c']);
86
+ (0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
87
+ expect(react_2.screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
88
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('3');
89
+ });
90
+ it('should re-render when array is replaced with new reference', async () => {
91
+ const obs = (0, peers_sdk_1.observable)(['a', 'b']);
92
+ (0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
93
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
94
+ (0, react_2.act)(() => {
95
+ obs(['a', 'b', 'c']); // New array reference
96
+ });
97
+ await (0, react_2.waitFor)(() => {
98
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('3');
99
+ });
100
+ });
101
+ it('should NOT re-render when array is mutated in place (requires immutable updates)', async () => {
102
+ // This tests the case where an array is mutated in place and notifySubscribers is called.
103
+ //
104
+ // IMPORTANT BEHAVIORAL NOTE:
105
+ // The OLD implementation (useState + useEffect) had special handling that would
106
+ // spread the array when it detected same-reference mutation:
107
+ // if (isArray(newData) && newData === data) setData([...newData])
108
+ //
109
+ // The NEW implementation (useSyncExternalStore) does NOT do this.
110
+ // useSyncExternalStore uses Object.is() comparison on getSnapshot results.
111
+ // If the reference is the same, React won't re-render.
112
+ //
113
+ // This is actually the CORRECT behavior - it encourages immutable data patterns
114
+ // which are required for React 18 concurrent rendering to work correctly.
115
+ // Mutating data in place can cause tearing in concurrent mode.
116
+ const obs = (0, peers_sdk_1.observable)(['a', 'b']);
117
+ (0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
118
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
119
+ (0, react_2.act)(() => {
120
+ // Mutate the array in place (BAD PRACTICE - don't do this!)
121
+ const arr = obs();
122
+ arr.push('c');
123
+ obs.notifySubscribers();
124
+ });
125
+ // The observable itself has the mutated value
126
+ expect(obs()).toEqual(['a', 'b', 'c']);
127
+ // But the component did NOT re-render because the reference didn't change
128
+ // This is expected with useSyncExternalStore - it requires immutable updates
129
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
130
+ });
131
+ it('should re-render when array is properly updated with spread', async () => {
132
+ // This is the recommended pattern for updating arrays
133
+ const obs = (0, peers_sdk_1.observable)(['a', 'b']);
134
+ (0, react_2.render)(react_1.default.createElement(ArrayTestComponent, { obs: obs }));
135
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('2');
136
+ (0, react_2.act)(() => {
137
+ obs([...obs(), 'c']); // Proper immutable update
138
+ });
139
+ await (0, react_2.waitFor)(() => {
140
+ expect(react_2.screen.getByTestId('array-length')).toHaveTextContent('3');
141
+ expect(react_2.screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
142
+ });
143
+ });
144
+ });
145
+ describe('subscription management', () => {
146
+ it('should unsubscribe when component unmounts', () => {
147
+ const obs = (0, peers_sdk_1.observable)('test');
148
+ const initialSubscriberCount = obs.subscriberCount();
149
+ const { unmount } = (0, react_2.render)(react_1.default.createElement(TestComponent, { obs: obs }));
150
+ // Should have subscribed
151
+ expect(obs.subscriberCount()).toBeGreaterThan(initialSubscriberCount);
152
+ unmount();
153
+ // Should have unsubscribed
154
+ expect(obs.subscriberCount()).toBe(initialSubscriberCount);
155
+ });
156
+ it('should handle multiple components subscribing to same observable', async () => {
157
+ const obs = (0, peers_sdk_1.observable)('shared');
158
+ (0, react_2.render)(react_1.default.createElement("div", null,
159
+ react_1.default.createElement(TestComponent, { obs: obs, testId: "value1" }),
160
+ react_1.default.createElement(TestComponent, { obs: obs, testId: "value2" })));
161
+ expect(react_2.screen.getByTestId('value1')).toHaveTextContent('"shared"');
162
+ expect(react_2.screen.getByTestId('value2')).toHaveTextContent('"shared"');
163
+ (0, react_2.act)(() => {
164
+ obs('updated');
165
+ });
166
+ await (0, react_2.waitFor)(() => {
167
+ expect(react_2.screen.getByTestId('value1')).toHaveTextContent('"updated"');
168
+ expect(react_2.screen.getByTestId('value2')).toHaveTextContent('"updated"');
169
+ });
170
+ });
171
+ });
172
+ describe('rapid updates', () => {
173
+ it('should handle rapid sequential updates correctly', async () => {
174
+ const obs = (0, peers_sdk_1.observable)(0);
175
+ function CounterComponent() {
176
+ const [value] = (0, hooks_1.useObservable)(obs);
177
+ return react_1.default.createElement("span", { "data-testid": "counter" }, value);
178
+ }
179
+ (0, react_2.render)(react_1.default.createElement(CounterComponent, null));
180
+ (0, react_2.act)(() => {
181
+ for (let i = 1; i <= 10; i++) {
182
+ obs(i);
183
+ }
184
+ });
185
+ await (0, react_2.waitFor)(() => {
186
+ expect(react_2.screen.getByTestId('counter')).toHaveTextContent('10');
187
+ });
188
+ });
189
+ });
190
+ });
191
+ describe('useObservableState', () => {
192
+ it('should create an observable with the initial value', () => {
193
+ (0, react_2.render)(react_1.default.createElement(ObservableStateTestComponent, { initialValue: 42 }));
194
+ expect(react_2.screen.getByTestId('state-value')).toHaveTextContent('42');
195
+ });
196
+ it('should re-render when the observable is updated', async () => {
197
+ (0, react_2.render)(react_1.default.createElement(ObservableStateTestComponent, { initialValue: 0 }));
198
+ expect(react_2.screen.getByTestId('state-value')).toHaveTextContent('0');
199
+ (0, react_2.act)(() => {
200
+ react_2.screen.getByTestId('increment').click();
201
+ });
202
+ await (0, react_2.waitFor)(() => {
203
+ expect(react_2.screen.getByTestId('state-value')).toHaveTextContent('1');
204
+ });
205
+ });
206
+ it('should maintain the same observable instance across re-renders', async () => {
207
+ let capturedObs = null;
208
+ function CaptureObsComponent() {
209
+ const obs = (0, hooks_1.useObservableState)(0);
210
+ if (!capturedObs) {
211
+ capturedObs = obs;
212
+ }
213
+ const [value] = (0, hooks_1.useObservable)(obs);
214
+ return (react_1.default.createElement("div", null,
215
+ react_1.default.createElement("span", { "data-testid": "value" }, value),
216
+ react_1.default.createElement("span", { "data-testid": "same-obs" }, obs === capturedObs ? 'same' : 'different'),
217
+ react_1.default.createElement("button", { "data-testid": "update", onClick: () => obs(obs() + 1) }, "Update")));
218
+ }
219
+ (0, react_2.render)(react_1.default.createElement(CaptureObsComponent, null));
220
+ expect(react_2.screen.getByTestId('same-obs')).toHaveTextContent('same');
221
+ // Trigger a re-render by updating the observable
222
+ (0, react_2.act)(() => {
223
+ react_2.screen.getByTestId('update').click();
224
+ });
225
+ await (0, react_2.waitFor)(() => {
226
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('1');
227
+ expect(react_2.screen.getByTestId('same-obs')).toHaveTextContent('same');
228
+ });
229
+ });
230
+ it('should not subscribe when doNotSubscribe is true', () => {
231
+ function NoSubscribeComponent() {
232
+ const obs = (0, hooks_1.useObservableState)(0, true); // doNotSubscribe = true
233
+ return (react_1.default.createElement("div", null,
234
+ react_1.default.createElement("span", { "data-testid": "value" }, obs()),
235
+ react_1.default.createElement("button", { "data-testid": "update", onClick: () => obs(obs() + 1) }, "Update")));
236
+ }
237
+ (0, react_2.render)(react_1.default.createElement(NoSubscribeComponent, null));
238
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('0');
239
+ // Update won't cause re-render since we're not subscribed
240
+ (0, react_2.act)(() => {
241
+ react_2.screen.getByTestId('update').click();
242
+ });
243
+ // Value in DOM should still show 0 since component didn't re-render
244
+ // But the observable itself has been updated
245
+ expect(react_2.screen.getByTestId('value')).toHaveTextContent('0');
246
+ });
247
+ });
@@ -0,0 +1,15 @@
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
+ import React from "react";
9
+ import { UserContext } from "@peers-app/peers-sdk";
10
+ interface GroupInviteListenerProps {
11
+ groupId: string;
12
+ userContext: UserContext;
13
+ }
14
+ export declare const GroupInviteListener: (props: GroupInviteListenerProps) => React.JSX.Element;
15
+ export {};
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ /**
3
+ * Component for admins to prepare group invitations and manage join requests.
4
+ *
5
+ * Shows in the group details Members tab for users with Admin role or above.
6
+ *
7
+ * Communicates with device layer through pvars (not direct imports).
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.GroupInviteListener = void 0;
44
+ const react_1 = __importStar(require("react"));
45
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
46
+ const hooks_1 = require("../../hooks");
47
+ const GroupInviteListener = (props) => {
48
+ const { groupId } = props;
49
+ // State
50
+ const [password, setPassword] = (0, react_1.useState)("");
51
+ const [isListening, setIsListening] = (0, react_1.useState)(false);
52
+ const [copied, setCopied] = (0, react_1.useState)(false);
53
+ const [approvalRole, setApprovalRole] = (0, react_1.useState)(peers_sdk_1.GroupMemberRole.Reader);
54
+ const [processingRequestId, setProcessingRequestId] = (0, react_1.useState)(null);
55
+ // Observables
56
+ const [listeners] = (0, hooks_1.useObservable)(peers_sdk_1.groupInviteListeners);
57
+ const [requests] = (0, hooks_1.useObservable)(peers_sdk_1.groupInviteRequests);
58
+ const [status] = (0, hooks_1.useObservable)(peers_sdk_1.groupInviteStatus);
59
+ // Check if we're currently listening for this group
60
+ const currentListener = listeners?.[groupId];
61
+ const pendingRequests = (requests || []).filter(r => r.groupId === groupId);
62
+ // Sync local state with listener state
63
+ (0, react_1.useEffect)(() => {
64
+ if (currentListener) {
65
+ setPassword(currentListener.password);
66
+ setIsListening(true);
67
+ }
68
+ else {
69
+ setIsListening(false);
70
+ }
71
+ }, [currentListener]);
72
+ // Generate a new password
73
+ const handleGeneratePassword = (0, react_1.useCallback)(() => {
74
+ const newPassword = (0, peers_sdk_1.generateInvitePassword)();
75
+ setPassword(newPassword);
76
+ }, []);
77
+ // Start listening for invitations (set pvar, device layer reacts)
78
+ const handleStartListening = (0, react_1.useCallback)(() => {
79
+ if (!password.trim() || password.length < 4) {
80
+ alert("Password must be at least 4 characters");
81
+ return;
82
+ }
83
+ // Set the action pvar - device layer will process it
84
+ (0, peers_sdk_1.groupInviteStartListening)({ groupId, password });
85
+ }, [groupId, password]);
86
+ // Stop listening (set pvar, device layer reacts)
87
+ const handleStopListening = (0, react_1.useCallback)(() => {
88
+ // Set the action pvar - device layer will process it
89
+ (0, peers_sdk_1.groupInviteStopListening)(groupId);
90
+ }, [groupId]);
91
+ // Copy password to clipboard
92
+ const handleCopyPassword = (0, react_1.useCallback)(async () => {
93
+ const success = await (0, peers_sdk_1.copyToClipboard)(password);
94
+ if (success) {
95
+ setCopied(true);
96
+ setTimeout(() => setCopied(false), 2000);
97
+ }
98
+ }, [password]);
99
+ // Approve a join request (set pvar, device layer reacts)
100
+ const handleApprove = (0, react_1.useCallback)((request) => {
101
+ setProcessingRequestId(request.requestId);
102
+ // Set the action pvar - device layer will process it
103
+ (0, peers_sdk_1.groupInviteProcessRequest)({
104
+ requestId: request.requestId,
105
+ approved: true,
106
+ role: approvalRole
107
+ });
108
+ // Clear processing state after a short delay
109
+ setTimeout(() => setProcessingRequestId(null), 1000);
110
+ }, [approvalRole]);
111
+ // Deny a join request (set pvar, device layer reacts)
112
+ const handleDeny = (0, react_1.useCallback)((request) => {
113
+ setProcessingRequestId(request.requestId);
114
+ // Set the action pvar - device layer will process it
115
+ (0, peers_sdk_1.groupInviteProcessRequest)({
116
+ requestId: request.requestId,
117
+ approved: false,
118
+ role: peers_sdk_1.GroupMemberRole.None
119
+ });
120
+ // Clear processing state after a short delay
121
+ setTimeout(() => setProcessingRequestId(null), 1000);
122
+ }, []);
123
+ return (react_1.default.createElement("div", { className: "card mb-3" },
124
+ react_1.default.createElement("div", { className: "card-header" },
125
+ react_1.default.createElement("h6", { className: "mb-0" },
126
+ react_1.default.createElement("i", { className: "bi-person-plus-fill me-2" }),
127
+ "Invite New Members")),
128
+ react_1.default.createElement("div", { className: "card-body" },
129
+ react_1.default.createElement("div", { className: "mb-3" },
130
+ react_1.default.createElement("label", { className: "form-label small text-muted" }, "Invitation Password"),
131
+ react_1.default.createElement("div", { className: "input-group" },
132
+ react_1.default.createElement("input", { type: "text", className: "form-control font-monospace", value: password, onChange: (e) => setPassword(e.target.value), placeholder: "Enter or generate a password...", disabled: isListening }),
133
+ react_1.default.createElement("button", { className: "btn btn-outline-secondary", onClick: handleGeneratePassword, disabled: isListening, title: "Generate random password" },
134
+ react_1.default.createElement("i", { className: "bi-shuffle" })),
135
+ react_1.default.createElement("button", { className: "btn btn-outline-secondary", onClick: handleCopyPassword, disabled: !password, title: "Copy password" },
136
+ react_1.default.createElement("i", { className: copied ? "bi-check-lg text-success" : "bi-clipboard" }))),
137
+ react_1.default.createElement("small", { className: "text-muted" }, "Share this password with people you want to invite. They can enter it in the \"Join Group\" screen.")),
138
+ react_1.default.createElement("div", { className: "d-flex gap-2" }, !isListening ? (react_1.default.createElement("button", { className: "btn btn-primary", onClick: handleStartListening, disabled: !password || password.length < 4 },
139
+ react_1.default.createElement("i", { className: "bi-broadcast me-2" }),
140
+ "Start Listening")) : (react_1.default.createElement("button", { className: "btn btn-danger", onClick: handleStopListening },
141
+ react_1.default.createElement("i", { className: "bi-stop-circle me-2" }),
142
+ "Stop Listening"))),
143
+ isListening && (react_1.default.createElement("div", { className: "alert alert-success mt-3 mb-0 d-flex align-items-center" },
144
+ react_1.default.createElement("div", { className: "spinner-border spinner-border-sm me-2", role: "status" },
145
+ react_1.default.createElement("span", { className: "visually-hidden" }, "Listening...")),
146
+ react_1.default.createElement("div", null,
147
+ react_1.default.createElement("strong", null, "Listening for join requests"),
148
+ react_1.default.createElement("br", null),
149
+ react_1.default.createElement("small", null,
150
+ "Password: ",
151
+ react_1.default.createElement("code", null, password))))),
152
+ status && (react_1.default.createElement("div", { className: "alert alert-info mt-3 mb-0" },
153
+ react_1.default.createElement("i", { className: "bi-info-circle me-2" }),
154
+ status)),
155
+ pendingRequests.length > 0 && (react_1.default.createElement("div", { className: "mt-3" },
156
+ react_1.default.createElement("h6", { className: "mb-2" },
157
+ react_1.default.createElement("i", { className: "bi-inbox-fill me-2" }),
158
+ "Pending Requests (",
159
+ pendingRequests.length,
160
+ ")"),
161
+ react_1.default.createElement("div", { className: "mb-2" },
162
+ react_1.default.createElement("label", { className: "form-label small text-muted" }, "Role for new members:"),
163
+ react_1.default.createElement("select", { className: "form-select form-select-sm", value: approvalRole, onChange: (e) => setApprovalRole(Number(e.target.value)), style: { width: "auto" } },
164
+ react_1.default.createElement("option", { value: peers_sdk_1.GroupMemberRole.Reader }, "Reader"),
165
+ react_1.default.createElement("option", { value: peers_sdk_1.GroupMemberRole.Writer }, "Writer"),
166
+ react_1.default.createElement("option", { value: peers_sdk_1.GroupMemberRole.Admin }, "Admin"))),
167
+ react_1.default.createElement("div", { className: "list-group" }, pendingRequests.map((request) => (react_1.default.createElement("div", { key: request.requestId, className: "list-group-item" },
168
+ react_1.default.createElement("div", { className: "d-flex justify-content-between align-items-center" },
169
+ react_1.default.createElement("div", null,
170
+ react_1.default.createElement("div", { className: "d-flex align-items-center" },
171
+ react_1.default.createElement("i", { className: "bi-person-fill me-2" }),
172
+ react_1.default.createElement("div", null,
173
+ react_1.default.createElement("strong", null, request.requester.name || request.requester.userId),
174
+ request.requester.name && (react_1.default.createElement("small", { className: "text-muted d-block" }, request.requester.userId)))),
175
+ react_1.default.createElement("small", { className: "text-muted" },
176
+ "Device: ",
177
+ request.requester.deviceId.slice(0, 8),
178
+ "...",
179
+ " | ",
180
+ "Received: ",
181
+ new Date(request.receivedAt).toLocaleTimeString())),
182
+ react_1.default.createElement("div", { className: "d-flex gap-2" },
183
+ react_1.default.createElement("button", { className: "btn btn-success btn-sm", onClick: () => handleApprove(request), disabled: processingRequestId === request.requestId }, processingRequestId === request.requestId ? (react_1.default.createElement("span", { className: "spinner-border spinner-border-sm" })) : (react_1.default.createElement(react_1.default.Fragment, null,
184
+ react_1.default.createElement("i", { className: "bi-check-lg me-1" }),
185
+ "Approve"))),
186
+ react_1.default.createElement("button", { className: "btn btn-outline-danger btn-sm", onClick: () => handleDeny(request), disabled: processingRequestId === request.requestId },
187
+ react_1.default.createElement("i", { className: "bi-x-lg" })))))))))))));
188
+ };
189
+ exports.GroupInviteListener = GroupInviteListener;
@@ -10,6 +10,7 @@ const loading_indicator_1 = require("../../components/loading-indicator");
10
10
  const trust_level_badge_1 = require("../../components/trust-level-badge");
11
11
  const typeahead_1 = require("../../components/typeahead");
12
12
  const hooks_1 = require("../../hooks");
13
+ const group_invite_listener_1 = require("./group-invite-listener");
13
14
  const GroupMembersUI = (props) => {
14
15
  const { groupId, userContext } = props;
15
16
  // Get the group's data context and tables
@@ -231,9 +232,10 @@ const GroupMembersUI = (props) => {
231
232
  react_1.default.createElement("strong", null,
232
233
  "Founder: ",
233
234
  founderData.founderUser?.name || founderData.founderUserId))))),
235
+ canEdit && (react_1.default.createElement(group_invite_listener_1.GroupInviteListener, { groupId: groupId, userContext: userContext })),
234
236
  canEdit && (react_1.default.createElement("div", { className: "card mb-3" },
235
237
  react_1.default.createElement("div", { className: "card-body" },
236
- react_1.default.createElement("h6", { className: "card-title" }, "Add Member"),
238
+ react_1.default.createElement("h6", { className: "card-title" }, "Add Existing Contact as Member"),
237
239
  react_1.default.createElement("div", { className: "row g-2" },
238
240
  react_1.default.createElement("div", { className: "col-md-6" },
239
241
  react_1.default.createElement(typeahead_1.Typeahead, { placeholder: "Search contacts to add...", searchFn: searchContacts, onSelectionChange: (items) => setSelectedContacts(items), renderItem: renderContactItem, renderBadge: renderContactBadge, selectedItems: selectedContacts.map(contact => ({ ...contact, id: contact.userId })), multiSelect: true, disabled: addingMember })),
@@ -0,0 +1,2 @@
1
+ import './join-group';
2
+ export * from './join-group';