@peers-app/peers-ui 0.8.3 → 0.8.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.
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
+ });
@@ -48,6 +48,8 @@ const color_mode_dropdown_1 = require("../settings/color-mode-dropdown");
48
48
  const log_display_1 = require("./log-display");
49
49
  const log_filters_1 = require("./log-filters");
50
50
  const resizable_table_header_1 = require("./resizable-table-header");
51
+ const globals_1 = require("../../globals");
52
+ const mobile_log_card_1 = require("./mobile-log-card");
51
53
  const windowHeight = () => window.innerHeight;
52
54
  const DEFAULT_COLUMNS = [
53
55
  { key: 'timestamp', label: 'Timestamp', width: 150 },
@@ -68,6 +70,8 @@ const ConsoleLogsList = () => {
68
70
  const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
69
71
  const logsEndRef = react_1.default.useRef(null);
70
72
  const containerRef = react_1.default.useRef(null);
73
+ (0, hooks_1.useObservable)(globals_1.isDesktop);
74
+ const isMobile = !(0, globals_1.isDesktop)();
71
75
  const batchSize = 50;
72
76
  // Track fixed column widths to trigger message column recalculation
73
77
  const fixedColumnsWidthKey = (0, react_1.useMemo)(() => {
@@ -225,7 +229,7 @@ const ConsoleLogsList = () => {
225
229
  marginBottom: '50px',
226
230
  overflow: 'hidden',
227
231
  } },
228
- react_1.default.createElement(resizable_table_header_1.ResizableTableHeader, { columns: columns, onColumnsChange: setColumns, colorMode: _colorMode }),
232
+ !isMobile && (react_1.default.createElement(resizable_table_header_1.ResizableTableHeader, { columns: columns, onColumnsChange: setColumns, colorMode: _colorMode })),
229
233
  react_1.default.createElement("div", { id: "scrollableLogsDiv", style: {
230
234
  flex: 1,
231
235
  overflow: 'auto',
@@ -239,10 +243,12 @@ const ConsoleLogsList = () => {
239
243
  react_1.default.createElement("div", { ref: logsEndRef }),
240
244
  _logs.length === 0 && allLogsLoaded ? (react_1.default.createElement("div", { className: "text-center p-5 text-muted" },
241
245
  react_1.default.createElement("i", { className: "bi bi-inbox display-1" }),
242
- react_1.default.createElement("p", { className: "mt-2" }, "No logs found"))) : (react_1.default.createElement("table", { className: "table table-sm table-hover mb-0", style: { fontSize: '0.85rem', tableLayout: 'fixed', width: '100%' } },
243
- react_1.default.createElement("tbody", null, _logs.map((log) => (
244
- // log.logId
245
- react_1.default.createElement(log_display_1.LogDisplay, { key: log.logId, log: log, columns: columns }))))))))),
246
+ react_1.default.createElement("p", { className: "mt-2" }, "No logs found"))) : isMobile ? (
247
+ /* Mobile: Card-based layout */
248
+ react_1.default.createElement("div", null, _logs.map((log) => (react_1.default.createElement(mobile_log_card_1.MobileLogCard, { key: log.logId, log: log, colorMode: _colorMode }))))) : (
249
+ /* Desktop: Table layout */
250
+ react_1.default.createElement("table", { className: "table table-sm table-hover mb-0", style: { fontSize: '0.85rem', tableLayout: 'fixed', width: '100%' } },
251
+ react_1.default.createElement("tbody", null, _logs.map((log) => (react_1.default.createElement(log_display_1.LogDisplay, { key: log.logId, log: log, columns: columns }))))))))),
246
252
  react_1.default.createElement("div", { style: {
247
253
  position: 'fixed',
248
254
  bottom: 0,
@@ -0,0 +1,8 @@
1
+ import { IConsoleLog } from "@peers-app/peers-sdk";
2
+ import React from 'react';
3
+ interface MobileLogCardProps {
4
+ log: IConsoleLog;
5
+ colorMode: string;
6
+ }
7
+ export declare const MobileLogCard: ({ log, colorMode }: MobileLogCardProps) => React.JSX.Element;
8
+ export {};
@@ -0,0 +1,96 @@
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
+ exports.MobileLogCard = void 0;
7
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
8
+ const moment_1 = __importDefault(require("moment"));
9
+ const react_1 = __importDefault(require("react"));
10
+ const getLevelColor = (level) => {
11
+ switch (level) {
12
+ case 'error': return '#dc3545';
13
+ case 'warn': return '#ffc107';
14
+ case 'info': return '#0dcaf0';
15
+ case 'log': return '#6c757d';
16
+ case 'debug': return '#6c757d';
17
+ default: return '#6c757d';
18
+ }
19
+ };
20
+ const getProcessColor = (process) => {
21
+ switch (process) {
22
+ case 'ui':
23
+ case 'renderer': return '#0dcaf0';
24
+ case 'electron':
25
+ case 'main': return '#d946ef';
26
+ case 'react-native': return '#61dafb';
27
+ default: return '#6c757d';
28
+ }
29
+ };
30
+ const getLevelIcon = (level) => {
31
+ switch (level) {
32
+ case 'error': return 'bi-x-circle-fill';
33
+ case 'warn': return 'bi-exclamation-triangle-fill';
34
+ case 'info': return 'bi-info-circle-fill';
35
+ case 'debug': return 'bi-bug-fill';
36
+ default: return 'bi-chat-square-text';
37
+ }
38
+ };
39
+ const MobileLogCard = ({ log, colorMode }) => {
40
+ const isDark = colorMode === 'dark';
41
+ // Parse context if it's a string
42
+ let context = log.context;
43
+ if (typeof context === 'string') {
44
+ try {
45
+ context = (0, peers_sdk_1.fromJSONString)(context);
46
+ }
47
+ catch (err) { }
48
+ }
49
+ return (react_1.default.createElement("div", { style: {
50
+ padding: '10px 12px',
51
+ borderBottom: `1px solid ${isDark ? '#333' : '#e9ecef'}`,
52
+ backgroundColor: isDark ? '#1a1a1a' : '#fff',
53
+ } },
54
+ react_1.default.createElement("div", { className: "d-flex align-items-center gap-2 mb-1", style: { flexWrap: 'wrap' } },
55
+ react_1.default.createElement("span", { style: { fontSize: '0.7rem', color: isDark ? '#888' : '#666' } }, (0, moment_1.default)(log.timestamp).format('HH:mm:ss.SSS')),
56
+ react_1.default.createElement("span", { className: "badge text-white", style: {
57
+ backgroundColor: getLevelColor(log.level),
58
+ fontSize: '0.65rem',
59
+ padding: '2px 6px'
60
+ } },
61
+ react_1.default.createElement("i", { className: `${getLevelIcon(log.level)} me-1` }),
62
+ log.level),
63
+ react_1.default.createElement("span", { className: "badge text-white", style: {
64
+ backgroundColor: getProcessColor(log.process),
65
+ fontSize: '0.65rem',
66
+ padding: '2px 6px'
67
+ } }, log.process),
68
+ log.source && (react_1.default.createElement("span", { style: { fontSize: '0.65rem', color: isDark ? '#666' : '#999' } }, log.source))),
69
+ react_1.default.createElement("div", { style: {
70
+ fontSize: '0.8rem',
71
+ wordBreak: 'break-word',
72
+ color: isDark ? '#e0e0e0' : '#333'
73
+ } }, log.message),
74
+ context && (react_1.default.createElement("details", { className: "mt-1" },
75
+ react_1.default.createElement("summary", { className: "text-muted", style: { cursor: 'pointer', fontSize: '0.7rem' } },
76
+ react_1.default.createElement("i", { className: "bi bi-code-square me-1" }),
77
+ "Context"),
78
+ react_1.default.createElement("pre", { className: "mt-1 p-2 rounded", style: {
79
+ fontSize: '0.65rem',
80
+ backgroundColor: isDark ? '#252525' : '#f8f9fa',
81
+ color: isDark ? '#aaa' : '#333',
82
+ overflow: 'auto',
83
+ maxHeight: '150px'
84
+ } }, JSON.stringify(context, null, 2)))),
85
+ log.stackTrace && (react_1.default.createElement("details", { className: "mt-1" },
86
+ react_1.default.createElement("summary", { className: "text-danger", style: { cursor: 'pointer', fontSize: '0.7rem' } },
87
+ react_1.default.createElement("i", { className: "bi bi-bug me-1" }),
88
+ "Stack Trace"),
89
+ react_1.default.createElement("pre", { className: "mt-1 p-2 rounded text-danger", style: {
90
+ fontSize: '0.6rem',
91
+ backgroundColor: isDark ? '#2a1a1a' : '#fff0f0',
92
+ overflow: 'auto',
93
+ maxHeight: '150px'
94
+ } }, log.stackTrace)))));
95
+ };
96
+ exports.MobileLogCard = MobileLogCard;
@@ -46,7 +46,8 @@ const SettingsPage = () => {
46
46
  react_1.default.createElement(DeviceName, null),
47
47
  react_1.default.createElement(PackagesRootDirectory, null),
48
48
  react_1.default.createElement(ReloadPackagesOnPageRefresh, null),
49
- react_1.default.createElement(ResetDeviceSyncInfos, null)));
49
+ react_1.default.createElement(ResetDeviceSyncInfos, null),
50
+ react_1.default.createElement(DeleteLocalDatabase, null)));
50
51
  };
51
52
  exports.SettingsPage = SettingsPage;
52
53
  const DeviceName = () => {
@@ -130,3 +131,20 @@ const ResetDeviceSyncInfos = () => {
130
131
  }
131
132
  } }, "Reset All Device Sync Info")));
132
133
  };
134
+ const DeleteLocalDatabase = () => {
135
+ return (react_1.default.createElement("div", { className: 'mt-3' },
136
+ react_1.default.createElement("button", { className: 'btn btn-danger btn-sm ms-2', onClick: async () => {
137
+ const confirmed = confirm('Are you sure you want to delete the local database for the current group? ' +
138
+ 'This will remove all local data. You will need to restart the app and re-sync from other devices.');
139
+ if (!confirmed)
140
+ return;
141
+ try {
142
+ await peers_sdk_1.rpcServerCalls.deleteLocalDatabase();
143
+ alert('Local database has been deleted. Please restart the app.');
144
+ }
145
+ catch (err) {
146
+ console.error('Error while deleting local database', err);
147
+ alert('Failed to delete local database: ' + err.message);
148
+ }
149
+ } }, "Delete Local Database")));
150
+ };
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  require("@testing-library/jest-dom");
4
+ const util_1 = require("util");
5
+ // Polyfill TextEncoder/TextDecoder for jsdom environment
6
+ global.TextEncoder = util_1.TextEncoder;
7
+ global.TextDecoder = util_1.TextDecoder;
4
8
  // Mock IntersectionObserver
5
9
  global.IntersectionObserver = class {
6
10
  constructor() { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -21,13 +21,13 @@
21
21
  "release:minor": "yarn clean && yarn build && npm version minor && git push && git push --tags",
22
22
  "release:major": "yarn clean && yarn build && npm version major && git push && git push --tags",
23
23
  "release": "yarn release:patch",
24
- "test": "echo 'no tests yet'",
24
+ "test": "jest --no-watchman",
25
25
  "test:watch": "jest --watch",
26
26
  "test:coverage": "jest --coverage"
27
27
  },
28
28
  "peerDependencies": {
29
+ "@peers-app/peers-sdk": "^0.8.5",
29
30
  "bootstrap": "^5.3.3",
30
- "@peers-app/peers-sdk": "^0.8.3",
31
31
  "react": "^18.0.0",
32
32
  "react-dom": "^18.0.0"
33
33
  },
@@ -37,13 +37,14 @@
37
37
  "@babel/preset-react": "^7.24.1",
38
38
  "@babel/preset-typescript": "^7.27.1",
39
39
  "@electron/rebuild": "^3.6.0",
40
+ "@peers-app/peers-sdk": "0.8.5",
40
41
  "@testing-library/dom": "^10.4.0",
41
42
  "@testing-library/jest-dom": "^6.6.3",
42
43
  "@testing-library/react": "^16.3.0",
43
44
  "@testing-library/user-event": "^14.6.1",
44
45
  "@types/bootstrap": "^5.2.10",
45
46
  "@types/dompurify": "^3.0.5",
46
- "@types/jest": "^29.5.12",
47
+ "@types/jest": "^30.0.0",
47
48
  "@types/lodash": "^4.17.1",
48
49
  "@types/node": "^20.12.10",
49
50
  "@types/prismjs": "^1.26.4",
@@ -57,7 +58,6 @@
57
58
  "jest": "^29.7.0",
58
59
  "jest-environment-jsdom": "^30.0.5",
59
60
  "path-browserify": "^1.0.1",
60
- "@peers-app/peers-sdk": "0.8.3",
61
61
  "react": "^18.0.0",
62
62
  "react-dom": "^18.0.0",
63
63
  "string-width": "^7.1.0",
@@ -0,0 +1,324 @@
1
+ import React from 'react';
2
+ import { render, screen, act, waitFor } from '@testing-library/react';
3
+ import { observable, Observable } from '@peers-app/peers-sdk';
4
+ import { useObservable, useObservableState } from './hooks';
5
+
6
+ // Helper component to test useObservable
7
+ function TestComponent<T>({ obs, testId = 'value' }: { obs: Observable<T> | T, testId?: string }) {
8
+ const [value, setValue] = useObservable(obs);
9
+ return (
10
+ <div>
11
+ <span data-testid={testId}>{JSON.stringify(value)}</span>
12
+ <button data-testid="setter" onClick={() => setValue('new-value' as any)}>Set</button>
13
+ </div>
14
+ );
15
+ }
16
+
17
+ // Helper component to test array behavior
18
+ function ArrayTestComponent({ obs }: { obs: Observable<string[]> }) {
19
+ const [value] = useObservable(obs);
20
+ return (
21
+ <div>
22
+ <span data-testid="array-value">{JSON.stringify(value)}</span>
23
+ <span data-testid="array-length">{value.length}</span>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ // Helper component to test useObservableState
29
+ function ObservableStateTestComponent({ initialValue }: { initialValue: number }) {
30
+ const obs = useObservableState(initialValue);
31
+ const [value] = useObservable(obs);
32
+ return (
33
+ <div>
34
+ <span data-testid="state-value">{value}</span>
35
+ <button data-testid="increment" onClick={() => obs(obs() + 1)}>Increment</button>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ describe('useObservable', () => {
41
+ describe('basic functionality', () => {
42
+ it('should return the current value of an observable', () => {
43
+ const obs = observable('hello');
44
+ render(<TestComponent obs={obs} />);
45
+ expect(screen.getByTestId('value')).toHaveTextContent('"hello"');
46
+ });
47
+
48
+ it('should return the value when passed a plain value (non-observable)', () => {
49
+ render(<TestComponent obs="plain-value" />);
50
+ expect(screen.getByTestId('value')).toHaveTextContent('"plain-value"');
51
+ });
52
+
53
+ it('should re-render when observable value changes', async () => {
54
+ const obs = observable('initial');
55
+ render(<TestComponent obs={obs} />);
56
+
57
+ expect(screen.getByTestId('value')).toHaveTextContent('"initial"');
58
+
59
+ act(() => {
60
+ obs('updated');
61
+ });
62
+
63
+ await waitFor(() => {
64
+ expect(screen.getByTestId('value')).toHaveTextContent('"updated"');
65
+ });
66
+ });
67
+
68
+ it('should update the observable when setter is called', async () => {
69
+ const obs = observable('initial');
70
+ render(<TestComponent obs={obs} />);
71
+
72
+ act(() => {
73
+ screen.getByTestId('setter').click();
74
+ });
75
+
76
+ await waitFor(() => {
77
+ expect(obs()).toBe('new-value');
78
+ expect(screen.getByTestId('value')).toHaveTextContent('"new-value"');
79
+ });
80
+ });
81
+
82
+ it('should handle undefined values', () => {
83
+ const obs = observable<string | undefined>(undefined);
84
+ render(<TestComponent obs={obs} />);
85
+ // undefined serializes to empty string in JSON.stringify, but actually shows as nothing
86
+ expect(screen.getByTestId('value')).toHaveTextContent('');
87
+ });
88
+
89
+ it('should handle object values', async () => {
90
+ const obs = observable({ name: 'test', count: 5 });
91
+ render(<TestComponent obs={obs} />);
92
+
93
+ expect(screen.getByTestId('value')).toHaveTextContent('{"name":"test","count":5}');
94
+
95
+ act(() => {
96
+ obs({ name: 'updated', count: 10 });
97
+ });
98
+
99
+ await waitFor(() => {
100
+ expect(screen.getByTestId('value')).toHaveTextContent('{"name":"updated","count":10}');
101
+ });
102
+ });
103
+ });
104
+
105
+ describe('array handling', () => {
106
+ it('should handle array values', () => {
107
+ const obs = observable(['a', 'b', 'c']);
108
+ render(<ArrayTestComponent obs={obs} />);
109
+
110
+ expect(screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
111
+ expect(screen.getByTestId('array-length')).toHaveTextContent('3');
112
+ });
113
+
114
+ it('should re-render when array is replaced with new reference', async () => {
115
+ const obs = observable(['a', 'b']);
116
+ render(<ArrayTestComponent obs={obs} />);
117
+
118
+ expect(screen.getByTestId('array-length')).toHaveTextContent('2');
119
+
120
+ act(() => {
121
+ obs(['a', 'b', 'c']); // New array reference
122
+ });
123
+
124
+ await waitFor(() => {
125
+ expect(screen.getByTestId('array-length')).toHaveTextContent('3');
126
+ });
127
+ });
128
+
129
+ it('should NOT re-render when array is mutated in place (requires immutable updates)', async () => {
130
+ // This tests the case where an array is mutated in place and notifySubscribers is called.
131
+ //
132
+ // IMPORTANT BEHAVIORAL NOTE:
133
+ // The OLD implementation (useState + useEffect) had special handling that would
134
+ // spread the array when it detected same-reference mutation:
135
+ // if (isArray(newData) && newData === data) setData([...newData])
136
+ //
137
+ // The NEW implementation (useSyncExternalStore) does NOT do this.
138
+ // useSyncExternalStore uses Object.is() comparison on getSnapshot results.
139
+ // If the reference is the same, React won't re-render.
140
+ //
141
+ // This is actually the CORRECT behavior - it encourages immutable data patterns
142
+ // which are required for React 18 concurrent rendering to work correctly.
143
+ // Mutating data in place can cause tearing in concurrent mode.
144
+
145
+ const obs = observable(['a', 'b']);
146
+ render(<ArrayTestComponent obs={obs} />);
147
+
148
+ expect(screen.getByTestId('array-length')).toHaveTextContent('2');
149
+
150
+ act(() => {
151
+ // Mutate the array in place (BAD PRACTICE - don't do this!)
152
+ const arr = obs();
153
+ arr.push('c');
154
+ obs.notifySubscribers();
155
+ });
156
+
157
+ // The observable itself has the mutated value
158
+ expect(obs()).toEqual(['a', 'b', 'c']);
159
+
160
+ // But the component did NOT re-render because the reference didn't change
161
+ // This is expected with useSyncExternalStore - it requires immutable updates
162
+ expect(screen.getByTestId('array-length')).toHaveTextContent('2');
163
+ });
164
+
165
+ it('should re-render when array is properly updated with spread', async () => {
166
+ // This is the recommended pattern for updating arrays
167
+ const obs = observable(['a', 'b']);
168
+ render(<ArrayTestComponent obs={obs} />);
169
+
170
+ expect(screen.getByTestId('array-length')).toHaveTextContent('2');
171
+
172
+ act(() => {
173
+ obs([...obs(), 'c']); // Proper immutable update
174
+ });
175
+
176
+ await waitFor(() => {
177
+ expect(screen.getByTestId('array-length')).toHaveTextContent('3');
178
+ expect(screen.getByTestId('array-value')).toHaveTextContent('["a","b","c"]');
179
+ });
180
+ });
181
+ });
182
+
183
+ describe('subscription management', () => {
184
+ it('should unsubscribe when component unmounts', () => {
185
+ const obs = observable('test');
186
+ const initialSubscriberCount = obs.subscriberCount();
187
+
188
+ const { unmount } = render(<TestComponent obs={obs} />);
189
+
190
+ // Should have subscribed
191
+ expect(obs.subscriberCount()).toBeGreaterThan(initialSubscriberCount);
192
+
193
+ unmount();
194
+
195
+ // Should have unsubscribed
196
+ expect(obs.subscriberCount()).toBe(initialSubscriberCount);
197
+ });
198
+
199
+ it('should handle multiple components subscribing to same observable', async () => {
200
+ const obs = observable('shared');
201
+
202
+ render(
203
+ <div>
204
+ <TestComponent obs={obs} testId="value1" />
205
+ <TestComponent obs={obs} testId="value2" />
206
+ </div>
207
+ );
208
+
209
+ expect(screen.getByTestId('value1')).toHaveTextContent('"shared"');
210
+ expect(screen.getByTestId('value2')).toHaveTextContent('"shared"');
211
+
212
+ act(() => {
213
+ obs('updated');
214
+ });
215
+
216
+ await waitFor(() => {
217
+ expect(screen.getByTestId('value1')).toHaveTextContent('"updated"');
218
+ expect(screen.getByTestId('value2')).toHaveTextContent('"updated"');
219
+ });
220
+ });
221
+ });
222
+
223
+ describe('rapid updates', () => {
224
+ it('should handle rapid sequential updates correctly', async () => {
225
+ const obs = observable(0);
226
+
227
+ function CounterComponent() {
228
+ const [value] = useObservable(obs);
229
+ return <span data-testid="counter">{value}</span>;
230
+ }
231
+
232
+ render(<CounterComponent />);
233
+
234
+ act(() => {
235
+ for (let i = 1; i <= 10; i++) {
236
+ obs(i);
237
+ }
238
+ });
239
+
240
+ await waitFor(() => {
241
+ expect(screen.getByTestId('counter')).toHaveTextContent('10');
242
+ });
243
+ });
244
+ });
245
+ });
246
+
247
+ describe('useObservableState', () => {
248
+ it('should create an observable with the initial value', () => {
249
+ render(<ObservableStateTestComponent initialValue={42} />);
250
+ expect(screen.getByTestId('state-value')).toHaveTextContent('42');
251
+ });
252
+
253
+ it('should re-render when the observable is updated', async () => {
254
+ render(<ObservableStateTestComponent initialValue={0} />);
255
+
256
+ expect(screen.getByTestId('state-value')).toHaveTextContent('0');
257
+
258
+ act(() => {
259
+ screen.getByTestId('increment').click();
260
+ });
261
+
262
+ await waitFor(() => {
263
+ expect(screen.getByTestId('state-value')).toHaveTextContent('1');
264
+ });
265
+ });
266
+
267
+ it('should maintain the same observable instance across re-renders', async () => {
268
+ let capturedObs: Observable<number> | null = null;
269
+
270
+ function CaptureObsComponent() {
271
+ const obs = useObservableState(0);
272
+ if (!capturedObs) {
273
+ capturedObs = obs;
274
+ }
275
+ const [value] = useObservable(obs);
276
+ return (
277
+ <div>
278
+ <span data-testid="value">{value}</span>
279
+ <span data-testid="same-obs">{obs === capturedObs ? 'same' : 'different'}</span>
280
+ <button data-testid="update" onClick={() => obs(obs() + 1)}>Update</button>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ render(<CaptureObsComponent />);
286
+
287
+ expect(screen.getByTestId('same-obs')).toHaveTextContent('same');
288
+
289
+ // Trigger a re-render by updating the observable
290
+ act(() => {
291
+ screen.getByTestId('update').click();
292
+ });
293
+
294
+ await waitFor(() => {
295
+ expect(screen.getByTestId('value')).toHaveTextContent('1');
296
+ expect(screen.getByTestId('same-obs')).toHaveTextContent('same');
297
+ });
298
+ });
299
+
300
+ it('should not subscribe when doNotSubscribe is true', () => {
301
+ function NoSubscribeComponent() {
302
+ const obs = useObservableState(0, true); // doNotSubscribe = true
303
+ return (
304
+ <div>
305
+ <span data-testid="value">{obs()}</span>
306
+ <button data-testid="update" onClick={() => obs(obs() + 1)}>Update</button>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ render(<NoSubscribeComponent />);
312
+
313
+ expect(screen.getByTestId('value')).toHaveTextContent('0');
314
+
315
+ // Update won't cause re-render since we're not subscribed
316
+ act(() => {
317
+ screen.getByTestId('update').click();
318
+ });
319
+
320
+ // Value in DOM should still show 0 since component didn't re-render
321
+ // But the observable itself has been updated
322
+ expect(screen.getByTestId('value')).toHaveTextContent('0');
323
+ });
324
+ });
package/src/hooks.ts CHANGED
@@ -1,47 +1,43 @@
1
1
 
2
- import React, { useState, useEffect } from 'react';
3
- import { isArray, isEqual } from 'lodash';
4
- import { observable, isSubscribable, unwrapObservable, Observable } from "@peers-app/peers-sdk";
2
+ import { isSubscribable, observable, Observable, unwrapObservable } from "@peers-app/peers-sdk";
3
+ import React, { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
5
4
 
6
5
 
7
6
 
8
7
  /**
9
8
  * Use this to subscribe to an observable or computed in a functional component.
9
+ * Uses useSyncExternalStore for proper React 18 concurrent rendering support.
10
10
  * @param sub the observable or computed to subscribe to
11
- * @param deps an array of dependencies to pass to useEffect which will trigger a re-render when any of the dependencies change
11
+ * @param deps an array of dependencies that will cause re-subscription when changed
12
12
  * @returns the current value of the observable or computed and a function to set the value
13
13
  */
14
14
  export function useObservable<T>(sub: Observable<T> | T, deps: React.DependencyList = []): [T, (value: T) => void] {
15
- const [data, setData] = useState(() => unwrapObservable(sub));
16
- useEffect(() => {
15
+ // Create memoized subscribe function that adapts Observable's subscribe API to useSyncExternalStore's expected signature
16
+ const subscribe = useCallback((onStoreChange: () => void) => {
17
17
  if (!isSubscribable(sub)) {
18
- return;
19
- }
20
- const subscription = sub.subscribe(() => {
21
- const newData = sub();
22
- // @ts-ignore
23
- if (isArray(newData) && newData === data) {
24
- // @ts-ignore
25
- setData([...newData])
26
- } else {
27
- setData(newData)
28
- }
29
- })
30
- // the data might change _after_ useObservable is called but _before_ the subscription has been created
31
- // this checks for that and updates the state with the new data if necessary
32
- const newData = sub();
33
- if (!isEqual(data, newData)) {
34
- setData(newData);
18
+ // If not subscribable, return a no-op unsubscribe
19
+ return () => {};
35
20
  }
21
+ const subscription = sub.subscribe(onStoreChange);
36
22
  return () => subscription.dispose();
37
- }, deps);
23
+ }, [sub, ...deps]);
38
24
 
39
- return [data, newData => {
40
- setData(newData);
25
+ // getSnapshot returns the current value from the observable
26
+ const getSnapshot = useCallback(() => {
27
+ return unwrapObservable(sub);
28
+ }, [sub, ...deps]);
29
+
30
+ // Use useSyncExternalStore for concurrent-safe subscription
31
+ const data = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
32
+
33
+ // Create setter that updates both local understanding and the observable
34
+ const setter = useCallback((newData: T) => {
41
35
  if (isSubscribable(sub)) {
42
36
  sub(newData);
43
37
  }
44
- }];
38
+ }, [sub]);
39
+
40
+ return [data, setter];
45
41
  }
46
42
 
47
43
  /**
@@ -120,27 +116,3 @@ export function useOnScreen(ref: React.RefObject<any>) {
120
116
 
121
117
  return isIntersecting
122
118
  }
123
-
124
- // /**
125
- // * This creates an observable that will automatically persist its value between page reloads using localStorage.
126
- // * If localStorage is not available, the observable will not persist its value.
127
- // * @param initialValue the initial value of the observable
128
- // * @param globalName the name to use when storing the value in localStorage
129
- // * @returns the observable that will persist between page reloads
130
- // */
131
- // export function persistentValue<T>(initialValue: T, globalName: string): Observable<T | undefined> {
132
- // let q = observable<T>();
133
- // if (typeof localStorage === 'undefined') {
134
- // return q;
135
- // }
136
- // q.subscribe(newVal => {
137
- // localStorage.setItem(globalName, JSON.stringify(toJSON(newVal)))
138
- // })
139
- // const existing = localStorage.getItem(globalName);
140
- // if (existing) {
141
- // q(fromJSON(JSON.parse(existing)))
142
- // } else {
143
- // q(initialValue);
144
- // }
145
- // return q;
146
- // }
@@ -9,6 +9,8 @@ import { colorMode } from '../settings/color-mode-dropdown';
9
9
  import { LogDisplay } from './log-display';
10
10
  import { LogFilters } from './log-filters';
11
11
  import { ResizableTableHeader } from './resizable-table-header';
12
+ import { isDesktop } from '../../globals';
13
+ import { MobileLogCard } from './mobile-log-card';
12
14
 
13
15
  const windowHeight = () => window.innerHeight;
14
16
 
@@ -37,7 +39,9 @@ export const ConsoleLogsList = () => {
37
39
  const [totalLogCount, setTotalLogCount] = useState<number>(0);
38
40
  const [_colorMode] = useObservable(colorMode);
39
41
  const logsEndRef = React.useRef<HTMLDivElement>(null);
40
- const containerRef = React.useRef<HTMLDivElement>(null);
42
+ const containerRef = React.useRef<HTMLDivElement>(null);
43
+ useObservable(isDesktop);
44
+ const isMobile = !isDesktop();
41
45
 
42
46
  const batchSize = 50;
43
47
 
@@ -226,12 +230,14 @@ export const ConsoleLogsList = () => {
226
230
  overflow: 'hidden',
227
231
  }}
228
232
  >
229
- {/* Resizable table header outside scroll area */}
230
- <ResizableTableHeader
231
- columns={columns}
232
- onColumnsChange={setColumns}
233
- colorMode={_colorMode}
234
- />
233
+ {/* Desktop: Resizable table header outside scroll area */}
234
+ {!isMobile && (
235
+ <ResizableTableHeader
236
+ columns={columns}
237
+ onColumnsChange={setColumns}
238
+ colorMode={_colorMode}
239
+ />
240
+ )}
235
241
 
236
242
  {/* Scrollable content area */}
237
243
  <div
@@ -267,11 +273,18 @@ export const ConsoleLogsList = () => {
267
273
  <i className="bi bi-inbox display-1"></i>
268
274
  <p className="mt-2">No logs found</p>
269
275
  </div>
276
+ ) : isMobile ? (
277
+ /* Mobile: Card-based layout */
278
+ <div>
279
+ {_logs.map((log) => (
280
+ <MobileLogCard key={log.logId} log={log} colorMode={_colorMode} />
281
+ ))}
282
+ </div>
270
283
  ) : (
284
+ /* Desktop: Table layout */
271
285
  <table className="table table-sm table-hover mb-0" style={{ fontSize: '0.85rem', tableLayout: 'fixed', width: '100%' }}>
272
286
  <tbody>
273
287
  {_logs.map((log) => (
274
- // log.logId
275
288
  <LogDisplay key={log.logId} log={log} columns={columns} />
276
289
  ))}
277
290
  </tbody>
@@ -0,0 +1,154 @@
1
+ import { fromJSONString, IConsoleLog } from "@peers-app/peers-sdk";
2
+ import moment from 'moment';
3
+ import React from 'react';
4
+
5
+ interface MobileLogCardProps {
6
+ log: IConsoleLog;
7
+ colorMode: string;
8
+ }
9
+
10
+ const getLevelColor = (level: string) => {
11
+ switch (level) {
12
+ case 'error': return '#dc3545';
13
+ case 'warn': return '#ffc107';
14
+ case 'info': return '#0dcaf0';
15
+ case 'log': return '#6c757d';
16
+ case 'debug': return '#6c757d';
17
+ default: return '#6c757d';
18
+ }
19
+ };
20
+
21
+ const getProcessColor = (process: string) => {
22
+ switch (process) {
23
+ case 'ui':
24
+ case 'renderer': return '#0dcaf0';
25
+ case 'electron':
26
+ case 'main': return '#d946ef';
27
+ case 'react-native': return '#61dafb';
28
+ default: return '#6c757d';
29
+ }
30
+ };
31
+
32
+ const getLevelIcon = (level: string) => {
33
+ switch (level) {
34
+ case 'error': return 'bi-x-circle-fill';
35
+ case 'warn': return 'bi-exclamation-triangle-fill';
36
+ case 'info': return 'bi-info-circle-fill';
37
+ case 'debug': return 'bi-bug-fill';
38
+ default: return 'bi-chat-square-text';
39
+ }
40
+ };
41
+
42
+ export const MobileLogCard = ({ log, colorMode }: MobileLogCardProps) => {
43
+ const isDark = colorMode === 'dark';
44
+
45
+ // Parse context if it's a string
46
+ let context = log.context;
47
+ if (typeof context === 'string') {
48
+ try {
49
+ context = fromJSONString(context);
50
+ } catch (err) {}
51
+ }
52
+
53
+ return (
54
+ <div
55
+ style={{
56
+ padding: '10px 12px',
57
+ borderBottom: `1px solid ${isDark ? '#333' : '#e9ecef'}`,
58
+ backgroundColor: isDark ? '#1a1a1a' : '#fff',
59
+ }}
60
+ >
61
+ {/* Header row: timestamp, level, process */}
62
+ <div className="d-flex align-items-center gap-2 mb-1" style={{ flexWrap: 'wrap' }}>
63
+ <span style={{ fontSize: '0.7rem', color: isDark ? '#888' : '#666' }}>
64
+ {moment(log.timestamp).format('HH:mm:ss.SSS')}
65
+ </span>
66
+ <span
67
+ className="badge text-white"
68
+ style={{
69
+ backgroundColor: getLevelColor(log.level),
70
+ fontSize: '0.65rem',
71
+ padding: '2px 6px'
72
+ }}
73
+ >
74
+ <i className={`${getLevelIcon(log.level)} me-1`}></i>
75
+ {log.level}
76
+ </span>
77
+ <span
78
+ className="badge text-white"
79
+ style={{
80
+ backgroundColor: getProcessColor(log.process),
81
+ fontSize: '0.65rem',
82
+ padding: '2px 6px'
83
+ }}
84
+ >
85
+ {log.process}
86
+ </span>
87
+ {log.source && (
88
+ <span style={{ fontSize: '0.65rem', color: isDark ? '#666' : '#999' }}>
89
+ {log.source}
90
+ </span>
91
+ )}
92
+ </div>
93
+
94
+ {/* Message */}
95
+ <div style={{
96
+ fontSize: '0.8rem',
97
+ wordBreak: 'break-word',
98
+ color: isDark ? '#e0e0e0' : '#333'
99
+ }}>
100
+ {log.message}
101
+ </div>
102
+
103
+ {/* Context (collapsible) */}
104
+ {context && (
105
+ <details className="mt-1">
106
+ <summary
107
+ className="text-muted"
108
+ style={{ cursor: 'pointer', fontSize: '0.7rem' }}
109
+ >
110
+ <i className="bi bi-code-square me-1"></i>
111
+ Context
112
+ </summary>
113
+ <pre
114
+ className="mt-1 p-2 rounded"
115
+ style={{
116
+ fontSize: '0.65rem',
117
+ backgroundColor: isDark ? '#252525' : '#f8f9fa',
118
+ color: isDark ? '#aaa' : '#333',
119
+ overflow: 'auto',
120
+ maxHeight: '150px'
121
+ }}
122
+ >
123
+ {JSON.stringify(context, null, 2)}
124
+ </pre>
125
+ </details>
126
+ )}
127
+
128
+ {/* Stack trace (collapsible) */}
129
+ {log.stackTrace && (
130
+ <details className="mt-1">
131
+ <summary
132
+ className="text-danger"
133
+ style={{ cursor: 'pointer', fontSize: '0.7rem' }}
134
+ >
135
+ <i className="bi bi-bug me-1"></i>
136
+ Stack Trace
137
+ </summary>
138
+ <pre
139
+ className="mt-1 p-2 rounded text-danger"
140
+ style={{
141
+ fontSize: '0.6rem',
142
+ backgroundColor: isDark ? '#2a1a1a' : '#fff0f0',
143
+ overflow: 'auto',
144
+ maxHeight: '150px'
145
+ }}
146
+ >
147
+ {log.stackTrace}
148
+ </pre>
149
+ </details>
150
+ )}
151
+ </div>
152
+ );
153
+ };
154
+
@@ -13,6 +13,7 @@ export const SettingsPage: React.FC = () => {
13
13
  <PackagesRootDirectory />
14
14
  <ReloadPackagesOnPageRefresh />
15
15
  <ResetDeviceSyncInfos />
16
+ <DeleteLocalDatabase />
16
17
  </div>
17
18
  );
18
19
  };
@@ -144,4 +145,30 @@ const ResetDeviceSyncInfos: React.FC = () => {
144
145
  </button>
145
146
  </div>
146
147
  );
148
+ }
149
+
150
+ const DeleteLocalDatabase: React.FC = () => {
151
+ return (
152
+ <div className='mt-3'>
153
+ <button
154
+ className='btn btn-danger btn-sm ms-2'
155
+ onClick={async () => {
156
+ const confirmed = confirm(
157
+ 'Are you sure you want to delete the local database for the current group? ' +
158
+ 'This will remove all local data. You will need to restart the app and re-sync from other devices.'
159
+ );
160
+ if (!confirmed) return;
161
+ try {
162
+ await rpcServerCalls.deleteLocalDatabase();
163
+ alert('Local database has been deleted. Please restart the app.');
164
+ } catch (err) {
165
+ console.error('Error while deleting local database', err);
166
+ alert('Failed to delete local database: ' + (err as Error).message);
167
+ }
168
+ }}
169
+ >
170
+ Delete Local Database
171
+ </button>
172
+ </div>
173
+ );
147
174
  }
package/src/setupTests.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import '@testing-library/jest-dom';
2
+ import { TextEncoder, TextDecoder } from 'util';
3
+
4
+ // Polyfill TextEncoder/TextDecoder for jsdom environment
5
+ global.TextEncoder = TextEncoder;
6
+ global.TextDecoder = TextDecoder as any;
2
7
 
3
8
  // Mock IntersectionObserver
4
9
  (global as any).IntersectionObserver = class {
package/tsconfig.json CHANGED
@@ -9,16 +9,14 @@
9
9
  "strict": true,
10
10
  "noImplicitAny": true,
11
11
  "strictNullChecks": true,
12
- "esModuleInterop": true
12
+ "esModuleInterop": true,
13
+ "types": ["jest", "node"]
13
14
  },
14
15
  "include": [
15
16
  "src/**/*.ts",
16
- "src/**/*.tsx",
17
+ "src/**/*.tsx"
17
18
  ],
18
19
  "exclude": [
19
- "src/**/__tests__/**/*",
20
- "src/**/*.test.*",
21
- "src/**/*.spec.*",
22
20
  "node_modules"
23
21
  ]
24
22
  }