@peers-app/peers-ui 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/command-palette/command-palette.d.ts +2 -2
  2. package/dist/command-palette/command-palette.js +3 -7
  3. package/dist/components/group-switcher.d.ts +2 -1
  4. package/dist/components/group-switcher.js +7 -6
  5. package/dist/screens/network-viewer/device-details-modal.js +44 -0
  6. package/dist/screens/network-viewer/group-details-modal.js +80 -2
  7. package/dist/screens/network-viewer/network-viewer.js +36 -16
  8. package/dist/screens/settings/settings-page.js +13 -7
  9. package/dist/screens/setup-user.js +8 -6
  10. package/dist/system-apps/index.d.ts +1 -0
  11. package/dist/system-apps/index.js +10 -1
  12. package/dist/system-apps/mobile-settings.app.d.ts +2 -0
  13. package/dist/system-apps/mobile-settings.app.js +8 -0
  14. package/dist/tabs-layout/tabs-layout.js +60 -38
  15. package/dist/tabs-layout/tabs-state.d.ts +10 -4
  16. package/dist/tabs-layout/tabs-state.js +41 -4
  17. package/dist/ui-router/ui-loader.js +45 -12
  18. package/package.json +3 -3
  19. package/src/command-palette/command-palette.ts +4 -8
  20. package/src/components/group-switcher.tsx +12 -8
  21. package/src/screens/network-viewer/device-details-modal.tsx +55 -0
  22. package/src/screens/network-viewer/group-details-modal.tsx +144 -1
  23. package/src/screens/network-viewer/network-viewer.tsx +36 -29
  24. package/src/screens/settings/settings-page.tsx +17 -9
  25. package/src/screens/setup-user.tsx +9 -6
  26. package/src/system-apps/index.ts +9 -0
  27. package/src/system-apps/mobile-settings.app.ts +8 -0
  28. package/src/tabs-layout/tabs-layout.tsx +108 -82
  29. package/src/tabs-layout/tabs-state.ts +54 -5
  30. package/src/ui-router/ui-loader.tsx +50 -11
@@ -85,7 +85,7 @@ export const SetupUser = () => {
85
85
  // Step 1: Select User Type
86
86
  if (step === 'select-user-type' && isExistingUser === null) {
87
87
  return (
88
- <div className="container-fluid d-flex align-items-start justify-content-center" style={{ minHeight: '100vh', paddingTop: '80px', backgroundColor: isDark ? '#212529' : '#f8f9fa' }}>
88
+ <div className="container-fluid d-flex align-items-start justify-content-center" style={{ paddingTop: '20px', backgroundColor: isDark ? '#212529' : '#f8f9fa' }}>
89
89
  <div className="card shadow" style={{ maxWidth: '500px', width: '100%', backgroundColor: isDark ? '#2d3238' : '#ffffff' }}>
90
90
  <div className="card-body p-4 p-md-5">
91
91
  <div className="text-center mb-4">
@@ -129,7 +129,7 @@ export const SetupUser = () => {
129
129
  // Step 2: New User Confirmation
130
130
  if (isExistingUser === false) {
131
131
  return (
132
- <div className="container-fluid d-flex align-items-start justify-content-center" style={{ minHeight: '100vh', paddingTop: '80px', backgroundColor: isDark ? '#212529' : '#f8f9fa' }}>
132
+ <div className="container-fluid d-flex align-items-start justify-content-center" style={{ paddingTop: '20px', backgroundColor: isDark ? '#212529' : '#f8f9fa' }}>
133
133
  <div className="card shadow" style={{ maxWidth: '500px', width: '100%', backgroundColor: isDark ? '#2d3238' : '#ffffff' }}>
134
134
  <div className="card-body p-4 p-md-5">
135
135
  <div className="text-center mb-4">
@@ -182,12 +182,15 @@ export const SetupUser = () => {
182
182
  // Step 3: Existing User Sign In
183
183
  if (isExistingUser === true) {
184
184
  return (
185
- <div className="container-fluid d-flex align-items-start justify-content-center" style={{ minHeight: '100vh', paddingTop: '80px', backgroundColor: isDark ? '#212529' : '#f8f9fa' }}>
185
+ <div className="container-fluid d-flex align-items-start justify-content-center" style={{ paddingTop: '20px', backgroundColor: isDark ? '#212529' : '#f8f9fa' }}>
186
186
  <div className="card shadow" style={{ maxWidth: '500px', width: '100%', backgroundColor: isDark ? '#2d3238' : '#ffffff' }}>
187
187
  <div className="card-body p-4 p-md-5">
188
- <div className="text-center mb-4">
189
- <i className="bi bi-box-arrow-in-right text-primary fs-1 mb-3 d-block" />
190
- <h3 className={`fw-bold mb-2 ${isDark ? 'text-light' : ''}`}>Sign In</h3>
188
+ <div className="text-center mb-2">
189
+ <h3 className={`fw-bold mb-2 ${isDark ? 'text-light' : ''}`}>
190
+ <span>Sign In</span>
191
+ &nbsp;&nbsp;
192
+ <i className="bi bi-box-arrow-in-right text-primary" />
193
+ </h3>
191
194
  <p className={isDark ? 'text-light opacity-75' : 'text-muted'}>Enter your existing credentials</p>
192
195
  </div>
193
196
 
@@ -20,6 +20,7 @@ export { contactsApp } from './contacts.app';
20
20
  export { consoleLogsApp } from './console-logs.app';
21
21
  export { networkViewerApp } from './network-viewer.app';
22
22
  export { dataExplorerApp } from './data-explorer.app';
23
+ export { mobileSettingsApp } from './mobile-settings.app';
23
24
 
24
25
  // Import individual apps
25
26
  import { searchApp } from './search.app';
@@ -41,6 +42,12 @@ import { contactsApp } from './contacts.app';
41
42
  import { consoleLogsApp } from './console-logs.app';
42
43
  import { networkViewerApp } from './network-viewer.app';
43
44
  import { dataExplorerApp } from './data-explorer.app';
45
+ import { mobileSettingsApp } from './mobile-settings.app';
46
+
47
+ // Helper to check if running in React Native
48
+ function isReactNative(): boolean {
49
+ return typeof (window as any).__NATIVE_THEME !== 'undefined';
50
+ }
44
51
 
45
52
  // Collection of all system apps
46
53
  export const systemApps: IAppNav[] = [
@@ -67,6 +74,8 @@ export const systemApps: IAppNav[] = [
67
74
  // User & Settings Apps
68
75
  profileApp,
69
76
  settingsApp,
77
+ // Mobile Settings (only in React Native)
78
+ ...(isReactNative() ? [mobileSettingsApp] : []),
70
79
 
71
80
  // System Tools & Debugging
72
81
  consoleLogsApp,
@@ -0,0 +1,8 @@
1
+ import { IAppNav } from "@peers-app/peers-sdk";
2
+
3
+ export const mobileSettingsApp: IAppNav = {
4
+ name: 'Mobile Settings',
5
+ iconClassName: 'bi-phone-fill',
6
+ navigationPath: 'mobile-settings'
7
+ };
8
+
@@ -133,7 +133,7 @@ function TabsLayoutInternal() {
133
133
  ) : (
134
134
  <div className="d-flex align-items-center px-1" style={{ height: '36px' }}>
135
135
  {/* Group Switcher */}
136
- <GroupSwitcher colorMode={_colorMode} />
136
+ <GroupSwitcher colorMode={_colorMode} isMobile={false} />
137
137
 
138
138
  {/* Search Button */}
139
139
  <button
@@ -207,112 +207,122 @@ interface MobileTabsHeaderProps {
207
207
  }
208
208
 
209
209
  function MobileTabsHeader({ tabs, activeTab, onSwitch, onClose, colorMode }: MobileTabsHeaderProps) {
210
- const [showTabDropdown, setShowTabDropdown] = React.useState(false);
210
+ const [showMenuDropdown, setShowMenuDropdown] = React.useState(false);
211
211
  const currentTab = tabs.find(t => t.tabId === activeTab);
212
212
  const nonLauncherTabs = tabs.filter(t => t.packageId !== 'launcher');
213
213
 
214
214
  return (
215
- <div className="d-flex align-items-center justify-content-between px-2" style={{ height: '36px' }}>
216
- {/* Left Side - Group Switcher, Search & App Launcher Buttons */}
215
+ <div className="d-flex align-items-center justify-content-between px-2 position-relative" style={{ height: '36px' }}>
216
+ {/* Left Side - Group Switcher */}
217
217
  <div className="d-flex align-items-center gap-1">
218
- <GroupSwitcher colorMode={colorMode} />
219
- <button
220
- className="btn btn-sm"
221
- onClick={openCommandPalette}
222
- title="Search everything"
223
- style={{
224
- minWidth: '36px',
225
- border: 'none',
226
- background: 'transparent',
227
- color: colorMode === 'light' ? '#6c757d' : '#adb5bd'
228
- }}
229
- onMouseEnter={(e) => {
230
- e.currentTarget.style.backgroundColor = colorMode === 'light' ? '#f8f9fa' : '#495057';
231
- e.currentTarget.style.color = colorMode === 'light' ? '#0d6efd' : '#ffffff';
232
- }}
233
- onMouseLeave={(e) => {
234
- e.currentTarget.style.backgroundColor = 'transparent';
235
- e.currentTarget.style.color = colorMode === 'light' ? '#6c757d' : '#adb5bd';
236
- }}
237
- >
238
- <i className="bi-search" />
239
- </button>
240
- <button
241
- className={`btn btn-sm ${colorMode === 'light' ? 'btn-outline-primary' : 'btn-outline-light'}`}
242
- onClick={() => onSwitch('launcher')}
243
- style={{ minWidth: '36px' }}
244
- >
245
- <i className="bi-grid-3x3-gap" />
246
- </button>
218
+ <GroupSwitcher colorMode={colorMode} isMobile={true} />
247
219
  </div>
248
220
 
249
- {/* Center - Current Tab Display */}
250
- <div className="d-flex align-items-center flex-grow-1 justify-content-center">
221
+ {/* Center - Current Tab Display (absolutely positioned to center on screen) */}
222
+ <div
223
+ className="d-flex align-items-center justify-content-center position-absolute"
224
+ style={{
225
+ left: '50%',
226
+ transform: 'translateX(-50%)',
227
+ pointerEvents: 'none',
228
+ maxWidth: 'calc(100% - 140px)'
229
+ }}
230
+ >
251
231
  {currentTab?.iconClassName && currentTab.packageId !== 'launcher' && (
252
232
  <i className={`${currentTab.iconClassName} me-2`} />
253
233
  )}
254
- <span className="fw-medium text-truncate me-2 small">
234
+ <span className="fw-medium text-truncate small">
255
235
  {currentTab?.title || 'Apps'}
256
236
  </span>
257
237
  </div>
258
238
 
259
- {/* Right Side - Tab Actions */}
260
- <div className="d-flex align-items-center gap-2">
261
- {/* Tab Switcher Dropdown (excluding launcher) */}
262
- {nonLauncherTabs.length > 0 && (
263
- <div className="dropdown">
264
- <button
265
- className={`btn btn-sm ${colorMode === 'light' ? 'btn-outline-dark' : 'btn-outline-light'}`}
266
- onClick={() => setShowTabDropdown(!showTabDropdown)}
267
- >
268
- <i className="bi-list" />
239
+ {/* Right Side - Hamburger Menu */}
240
+ <div className="d-flex align-items-center">
241
+ <div className="dropdown">
242
+ <button
243
+ className={`btn btn-sm ${colorMode === 'light' ? 'btn-outline-dark' : 'btn-outline-light'}`}
244
+ onClick={() => setShowMenuDropdown(!showMenuDropdown)}
245
+ style={{ minWidth: '36px' }}
246
+ >
247
+ <i className="bi-list" />
248
+ {nonLauncherTabs.length > 0 && (
269
249
  <span className="ms-1">{nonLauncherTabs.length}</span>
270
- </button>
271
- {showTabDropdown && (
250
+ )}
251
+ </button>
252
+ {showMenuDropdown && (
253
+ <div
254
+ className={`dropdown-menu show position-absolute ${colorMode === 'light' ? '' : 'dropdown-menu-dark'}`}
255
+ style={{ right: 0, top: '100%', zIndex: 1000, minWidth: '250px' }}
256
+ >
257
+ {/* Apps Option */}
272
258
  <div
273
- className={`dropdown-menu show position-absolute ${colorMode === 'light' ? '' : 'dropdown-menu-dark'}`}
274
- style={{ right: 0, top: '100%', zIndex: 1000, minWidth: '250px' }}
259
+ className={`dropdown-item d-flex align-items-center ${activeTab === 'launcher' ? 'active' : ''}`}
260
+ style={{ cursor: 'pointer' }}
261
+ onClick={() => {
262
+ onSwitch('launcher');
263
+ setShowMenuDropdown(false);
264
+ }}
275
265
  >
276
- {nonLauncherTabs.slice().reverse().map(tab => (
277
- <div
278
- key={tab.tabId}
279
- className={`dropdown-item d-flex align-items-center justify-content-between ${activeTab === tab.tabId ? 'active' : ''}`}
280
- style={{ cursor: 'pointer' }}
281
- onClick={() => {
282
- onSwitch(tab.tabId);
283
- setShowTabDropdown(false);
266
+ <i className="bi-grid-3x3-gap me-2" />
267
+ <span>Apps</span>
268
+ </div>
269
+
270
+ {/* Search Option */}
271
+ <div
272
+ className="dropdown-item d-flex align-items-center"
273
+ style={{ cursor: 'pointer' }}
274
+ onClick={() => {
275
+ openCommandPalette();
276
+ setShowMenuDropdown(false);
277
+ }}
278
+ >
279
+ <i className="bi-search me-2" />
280
+ <span>Search</span>
281
+ </div>
282
+
283
+ {/* Divider if there are open tabs */}
284
+ {nonLauncherTabs.length > 0 && (
285
+ <div className="dropdown-divider" />
286
+ )}
287
+
288
+ {/* Open Tabs */}
289
+ {nonLauncherTabs.slice().reverse().map(tab => (
290
+ <div
291
+ key={tab.tabId}
292
+ className={`dropdown-item d-flex align-items-center justify-content-between ${activeTab === tab.tabId ? 'active' : ''}`}
293
+ style={{ cursor: 'pointer' }}
294
+ onClick={() => {
295
+ onSwitch(tab.tabId);
296
+ setShowMenuDropdown(false);
297
+ }}
298
+ >
299
+ <div className="d-flex align-items-center">
300
+ {tab.iconClassName && <i className={`${tab.iconClassName} me-2`} />}
301
+ <span>{tab.title}</span>
302
+ </div>
303
+ <button
304
+ className="btn btn-sm p-0 ms-2"
305
+ style={{ width: '20px', height: '20px' }}
306
+ onClick={(e) => {
307
+ e.stopPropagation();
308
+ onClose(tab.tabId);
284
309
  }}
285
310
  >
286
- <div className="d-flex align-items-center">
287
- {tab.iconClassName && <i className={`${tab.iconClassName} me-2`} />}
288
- <span>{tab.title}</span>
289
- </div>
290
- {tab.tabId !== "launcher" && (
291
- <button
292
- className="btn btn-sm p-0 ms-2"
293
- style={{ width: '20px', height: '20px' }}
294
- onClick={(e) => {
295
- e.stopPropagation();
296
- onClose(tab.tabId);
297
- }}
298
- >
299
- <i className="bi-x" />
300
- </button>
301
- )}
302
- </div>
303
- ))}
304
- </div>
305
- )}
306
- </div>
307
- )}
311
+ <i className="bi-x" />
312
+ </button>
313
+ </div>
314
+ ))}
315
+ </div>
316
+ )}
317
+ </div>
308
318
  </div>
309
319
 
310
320
  {/* Backdrop to close dropdown */}
311
- {showTabDropdown && (
321
+ {showMenuDropdown && (
312
322
  <div
313
323
  className="position-fixed w-100 h-100"
314
324
  style={{ top: 0, left: 0, zIndex: 999 }}
315
- onClick={() => setShowTabDropdown(false)}
325
+ onClick={() => setShowMenuDropdown(false)}
316
326
  />
317
327
  )}
318
328
  </div>
@@ -515,6 +525,22 @@ function AppLauncherTab({ isMobile }: AppLauncherTabProps) {
515
525
  .filter(Boolean) as AppItem[];
516
526
 
517
527
  const openApp = (appItem: AppItem) => {
528
+ // Check if this is the mobile-settings app and we're in React Native
529
+ if (appItem.path === 'mobile-settings' && typeof (window as any).__NATIVE_THEME !== 'undefined') {
530
+ // Use expo-linking to navigate to native screen
531
+ // @ts-ignore
532
+ if (window.ReactNativeWebView) {
533
+ // @ts-ignore
534
+ window.ReactNativeWebView.postMessage(JSON.stringify({
535
+ type: 'navigate',
536
+ path: 'mobile-settings'
537
+ }));
538
+ } else {
539
+ // Fallback to deep link
540
+ window.location.href = 'peers://mobile-settings';
541
+ }
542
+ return;
543
+ }
518
544
  goToTabPath(appItem.path);
519
545
  };
520
546
 
@@ -1,4 +1,4 @@
1
- import { groupDeviceVar, groupUserVar, IAppNav, IPackage, newid } from "@peers-app/peers-sdk";
1
+ import { groupDeviceVar, groupUserVar, IAppNav, IPackage, newid, observable, Observable } from "@peers-app/peers-sdk";
2
2
  import { _mainContentPath } from "../globals";
3
3
  import { systemPackage } from "../system-apps";
4
4
  import { allPackages } from "../ui-router/routes-loader";
@@ -19,19 +19,68 @@ export const launcherApp: TabState = {
19
19
  iconClassName: 'bi-grid-3x3-gap',
20
20
  };
21
21
 
22
- // Global persistent variables for tab state
23
- export const activeTabs = groupDeviceVar<TabState[]>('activeTabs', {
22
+ // Persistent vars for storage (write-only, no subscription to avoid oscillation)
23
+ const _persistentActiveTabs = groupDeviceVar<TabState[]>('activeTabs', {
24
24
  defaultValue: [launcherApp],
25
25
  });
26
26
 
27
- export const activeTabId = groupDeviceVar<string>('activeTabId', {
27
+ const _persistentActiveTabId = groupDeviceVar<string>('activeTabId', {
28
28
  defaultValue: 'launcher',
29
29
  });
30
30
 
31
- export const recentlyUsedApps = groupUserVar<string[]>('recentlyUsedApps', {
31
+ const _persistentRecentlyUsedApps = groupUserVar<string[]>('recentlyUsedApps', {
32
32
  defaultValue: [],
33
33
  });
34
34
 
35
+ // In-memory observables for UI state (loaded once, then write-through to persistent vars)
36
+ export const activeTabs: Observable<TabState[]> & { loadingPromise: Promise<void> } = (() => {
37
+ const obs = observable<TabState[]>([launcherApp]);
38
+
39
+ // Write-through to persistent var on change
40
+ obs.subscribe(value => {
41
+ _persistentActiveTabs(value);
42
+ });
43
+
44
+ // Load initial value once
45
+ const loadingPromise = _persistentActiveTabs.loadingPromise.then(() => {
46
+ obs(_persistentActiveTabs());
47
+ });
48
+
49
+ return Object.assign(obs, { loadingPromise });
50
+ })();
51
+
52
+ export const activeTabId: Observable<string> & { loadingPromise: Promise<void> } = (() => {
53
+ const obs = observable<string>('launcher');
54
+
55
+ // Write-through to persistent var on change
56
+ obs.subscribe(value => {
57
+ _persistentActiveTabId(value);
58
+ });
59
+
60
+ // Load initial value once
61
+ const loadingPromise = _persistentActiveTabId.loadingPromise.then(() => {
62
+ obs(_persistentActiveTabId());
63
+ });
64
+
65
+ return Object.assign(obs, { loadingPromise });
66
+ })();
67
+
68
+ export const recentlyUsedApps: Observable<string[]> & { loadingPromise: Promise<void> } = (() => {
69
+ const obs = observable<string[]>([]);
70
+
71
+ // Write-through to persistent var on change
72
+ obs.subscribe(value => {
73
+ _persistentRecentlyUsedApps(value);
74
+ });
75
+
76
+ // Load initial value once
77
+ const loadingPromise = _persistentRecentlyUsedApps.loadingPromise.then(() => {
78
+ obs(_persistentRecentlyUsedApps());
79
+ });
80
+
81
+ return Object.assign(obs, { loadingPromise });
82
+ })();
83
+
35
84
  export const initializedTabs = new Set<string>();
36
85
 
37
86
  export function goToTabPath(path: string) {
@@ -221,26 +221,65 @@ const UILoader = (args: { peersUIId: string, props: Record<string, any> }) => {
221
221
  }
222
222
 
223
223
  const uiLoadingPromises: Record<string, Promise<any>> = {};
224
+
225
+ // Check if we're running in a React Native WebView (has injectUIBundle available)
226
+ const isReactNativeWebView = typeof (window as any).ReactNativeWebView !== 'undefined';
227
+
224
228
  function loadUIBundle(pkg: IPackage, forceRefresh?: boolean) {
225
229
  // Dynamically import the bundle
226
230
  let importPromise: Promise<any> = uiLoadingPromises[pkg.packageId];
227
231
  if (!importPromise || forceRefresh) {
232
+ const sTime = Date.now();
228
233
  console.log(`loading ui bundle for ${pkg.name}`);
229
234
  importPromise = new Promise<void>(async (resolve, reject) => {
230
235
  try {
231
- let bundleCode = '';
232
- if (pkg.uiBundleFileId) {
233
- bundleCode = await rpcServerCalls.getFileContents(pkg.uiBundleFileId);
236
+ if (!pkg.uiBundleFileId) {
237
+ resolve();
238
+ return;
234
239
  }
235
- if (bundleCode) {
236
- const exportUIs = (peerUIs: IPeersPackageUIs) => {
237
- // TODO maybe add packageId that this came from
238
- peerUIs?.uis?.forEach(ui => {
239
- peersUIs[ui.peersUIId] = ui;
240
- });
240
+
241
+ // Use fast injection path for React Native WebView
242
+ if (isReactNativeWebView && rpcServerCalls.injectUIBundle) {
243
+ // Set up listeners for bundle load completion
244
+ const _window = window as any;
245
+ _window.__peersUIs = _window.__peersUIs || {};
246
+
247
+ const loadPromise = new Promise<void>((resolveLoad, rejectLoad) => {
248
+ const fileId = pkg.uiBundleFileId!;
249
+
250
+ _window.__peersUIBundleLoaded = (loadedFileId: string) => {
251
+ if (loadedFileId === fileId) {
252
+ // Copy loaded UIs to our local registry
253
+ Object.keys(_window.__peersUIs || {}).forEach(peersUIId => {
254
+ peersUIs[peersUIId] = _window.__peersUIs[peersUIId];
255
+ });
256
+ resolveLoad();
257
+ }
258
+ };
259
+
260
+ _window.__peersUIBundleError = (errorFileId: string, errorMsg: string) => {
261
+ if (errorFileId === fileId) {
262
+ rejectLoad(new Error(errorMsg));
263
+ }
264
+ };
265
+ });
266
+
267
+ await rpcServerCalls.injectUIBundle(pkg.uiBundleFileId);
268
+ await loadPromise;
269
+ console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms`);
270
+ } else {
271
+ // Fallback: use postMessage-based getFileContents (slower for large bundles)
272
+ let bundleCode = await rpcServerCalls.getFileContents(pkg.uiBundleFileId);
273
+ if (bundleCode) {
274
+ const exportUIs = (peerUIs: IPeersPackageUIs) => {
275
+ peerUIs?.uis?.forEach(ui => {
276
+ peersUIs[ui.peersUIId] = ui;
277
+ });
278
+ }
279
+ const bundleFunction = new Function('exportUIs', bundleCode);
280
+ await bundleFunction(exportUIs);
241
281
  }
242
- const bundleFunction = new Function('exportUIs', bundleCode);
243
- await bundleFunction(exportUIs);
282
+ console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms, ${(bundleCode.length/1000).toFixed(0)} KB`);
244
283
  }
245
284
  resolve();
246
285
  } catch (err) {