@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.
- package/dist/components/left-bar.js +1 -3
- package/dist/components/router.d.ts +1 -0
- package/dist/components/router.js +2 -5
- package/dist/hooks.d.ts +3 -2
- package/dist/hooks.js +22 -53
- package/dist/hooks.test.d.ts +1 -0
- package/dist/hooks.test.js +247 -0
- package/dist/screens/groups/group-invite-listener.d.ts +15 -0
- package/dist/screens/groups/group-invite-listener.js +189 -0
- package/dist/screens/groups/group-members.js +3 -1
- package/dist/screens/join-group/index.d.ts +2 -0
- package/dist/screens/join-group/index.js +19 -0
- package/dist/screens/join-group/join-group.d.ts +13 -0
- package/dist/screens/join-group/join-group.js +202 -0
- package/dist/screens/settings/settings-page.js +95 -29
- package/dist/screens/welcome-modal.d.ts +7 -0
- package/dist/screens/welcome-modal.js +193 -0
- package/dist/setupTests.js +4 -0
- package/dist/system-apps/index.d.ts +1 -0
- package/dist/system-apps/index.js +5 -1
- package/dist/system-apps/join-group.app.d.ts +2 -0
- package/dist/system-apps/join-group.app.js +9 -0
- package/dist/tabs-layout/tabs-layout.js +14 -3
- package/package.json +5 -5
- package/src/components/left-bar.tsx +1 -2
- package/src/components/router.tsx +2 -5
- package/src/hooks.test.tsx +324 -0
- package/src/hooks.ts +23 -51
- package/src/screens/groups/group-invite-listener.tsx +285 -0
- package/src/screens/groups/group-members.tsx +7 -1
- package/src/screens/join-group/index.ts +4 -0
- package/src/screens/join-group/join-group.tsx +294 -0
- package/src/screens/settings/settings-page.tsx +154 -40
- package/src/screens/welcome-modal.tsx +258 -0
- package/src/setupTests.ts +5 -0
- package/src/system-apps/index.ts +3 -0
- package/src/system-apps/join-group.app.ts +8 -0
- package/src/tabs-layout/tabs-layout.tsx +21 -3
- 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: "#
|
|
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
|
|
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
|
|
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
|
-
|
|
19
|
-
(0, react_1.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 })),
|