@peers-app/peers-ui 0.7.40 → 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 (39) 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/globals.d.ts +1 -1
  6. package/dist/screens/contacts/contact-list.js +4 -1
  7. package/dist/screens/contacts/index.d.ts +2 -0
  8. package/dist/screens/contacts/index.js +2 -0
  9. package/dist/screens/contacts/user-connect.d.ts +2 -0
  10. package/dist/screens/contacts/user-connect.js +312 -0
  11. package/dist/screens/network-viewer/device-details-modal.js +44 -0
  12. package/dist/screens/network-viewer/group-details-modal.js +80 -2
  13. package/dist/screens/network-viewer/network-viewer.js +36 -16
  14. package/dist/screens/settings/settings-page.js +13 -7
  15. package/dist/screens/setup-user.js +8 -6
  16. package/dist/system-apps/index.d.ts +1 -0
  17. package/dist/system-apps/index.js +10 -1
  18. package/dist/system-apps/mobile-settings.app.d.ts +2 -0
  19. package/dist/system-apps/mobile-settings.app.js +8 -0
  20. package/dist/tabs-layout/tabs-layout.js +60 -38
  21. package/dist/tabs-layout/tabs-state.d.ts +10 -4
  22. package/dist/tabs-layout/tabs-state.js +41 -4
  23. package/dist/ui-router/ui-loader.js +45 -12
  24. package/package.json +3 -3
  25. package/src/command-palette/command-palette.ts +4 -8
  26. package/src/components/group-switcher.tsx +12 -8
  27. package/src/screens/contacts/contact-list.tsx +4 -0
  28. package/src/screens/contacts/index.ts +3 -1
  29. package/src/screens/contacts/user-connect.tsx +452 -0
  30. package/src/screens/network-viewer/device-details-modal.tsx +55 -0
  31. package/src/screens/network-viewer/group-details-modal.tsx +144 -1
  32. package/src/screens/network-viewer/network-viewer.tsx +36 -29
  33. package/src/screens/settings/settings-page.tsx +17 -9
  34. package/src/screens/setup-user.tsx +9 -6
  35. package/src/system-apps/index.ts +9 -0
  36. package/src/system-apps/mobile-settings.app.ts +8 -0
  37. package/src/tabs-layout/tabs-layout.tsx +108 -82
  38. package/src/tabs-layout/tabs-state.ts +54 -5
  39. package/src/ui-router/ui-loader.tsx +50 -11
@@ -17,6 +17,22 @@ interface IGroupDetails {
17
17
  }[];
18
18
  }
19
19
 
20
+ interface IConnectedDevice {
21
+ deviceId: string;
22
+ latencyMs: number;
23
+ errorRate: number;
24
+ timestampLastApplied: number;
25
+ }
26
+
27
+ interface IGroupSyncInfo {
28
+ groupId: string;
29
+ groupName: string;
30
+ connectedDevices: IConnectedDevice[];
31
+ preferredDeviceIds: string[];
32
+ lastSyncTimestamp: number;
33
+ totalMembers: number;
34
+ }
35
+
20
36
  interface IGroupDetailsModalProps {
21
37
  groupId: string;
22
38
  onClose: () => void;
@@ -24,8 +40,17 @@ interface IGroupDetailsModalProps {
24
40
 
25
41
  export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps) {
26
42
  const [group, setGroup] = useState<IGroupDetails | null>(null);
43
+ const [syncInfo, setSyncInfo] = useState<IGroupSyncInfo | null>(null);
27
44
  const [loading, setLoading] = useState(true);
28
45
  const [notFound, setNotFound] = useState(false);
46
+ const [downloading, setDownloading] = useState(false);
47
+ const [downloadProgress, setDownloadProgress] = useState<string | null>(null);
48
+ const [downloadResult, setDownloadResult] = useState<{
49
+ success: boolean;
50
+ message: string;
51
+ filePath?: string;
52
+ size?: number;
53
+ } | null>(null);
29
54
 
30
55
  const loadData = async () => {
31
56
  try {
@@ -35,7 +60,10 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
35
60
  return;
36
61
  }
37
62
 
38
- const groupData = await api.getGroupDetails(groupId);
63
+ const [groupData, syncData] = await Promise.all([
64
+ api.getGroupDetails(groupId),
65
+ api.getGroupSyncStatus(groupId)
66
+ ]);
39
67
 
40
68
  if (!groupData) {
41
69
  setNotFound(true);
@@ -43,6 +71,10 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
43
71
  setGroup(groupData);
44
72
  setNotFound(false);
45
73
  }
74
+
75
+ if (syncData && syncData.length > 0) {
76
+ setSyncInfo(syncData[0]);
77
+ }
46
78
  } catch (error) {
47
79
  console.error('Error loading group details:', error);
48
80
  setNotFound(true);
@@ -51,6 +83,40 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
51
83
  }
52
84
  };
53
85
 
86
+ const handleDownloadDatabase = async (deviceId: string) => {
87
+ try {
88
+ setDownloading(true);
89
+ setDownloadProgress('Preparing download...');
90
+ setDownloadResult(null);
91
+
92
+ const api = (window as any).electronAPI?.networkViewer;
93
+ if (!api?.downloadRemoteDatabase) {
94
+ setDownloadResult({
95
+ success: false,
96
+ message: 'Download API not available'
97
+ });
98
+ return;
99
+ }
100
+
101
+ setDownloadProgress('Downloading database...');
102
+ const result = await api.downloadRemoteDatabase(groupId, deviceId);
103
+
104
+ setDownloadResult(result);
105
+ if (result.success) {
106
+ setDownloadProgress(null);
107
+ }
108
+ } catch (error: any) {
109
+ console.error('Error downloading database:', error);
110
+ setDownloadResult({
111
+ success: false,
112
+ message: error?.message || 'Download failed'
113
+ });
114
+ } finally {
115
+ setDownloading(false);
116
+ setDownloadProgress(null);
117
+ }
118
+ };
119
+
54
120
  useEffect(() => {
55
121
  loadData();
56
122
  }, [groupId]);
@@ -168,6 +234,83 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
168
234
  </div>
169
235
  </div>
170
236
  )}
237
+
238
+ {/* Database Download Section */}
239
+ <div className="mb-4">
240
+ <h6 className="border-bottom pb-2 mb-3">
241
+ <i className="bi bi-download me-2"></i>
242
+ Download Database (Fast Sync)
243
+ </h6>
244
+ <p className="text-muted small mb-3">
245
+ Download the entire database from a connected device. This is useful for fast initial sync on a new device.
246
+ The database will be saved as a backup file.
247
+ </p>
248
+
249
+ {syncInfo && syncInfo.connectedDevices.length > 0 ? (
250
+ <div>
251
+ <label className="form-label">Select a connected device:</label>
252
+ <div className="d-flex flex-wrap gap-2">
253
+ {syncInfo.connectedDevices.map((device) => (
254
+ <button
255
+ key={device.deviceId}
256
+ className="btn btn-outline-primary btn-sm"
257
+ onClick={() => handleDownloadDatabase(device.deviceId)}
258
+ disabled={downloading}
259
+ >
260
+ {downloading ? (
261
+ <>
262
+ <span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
263
+ Downloading...
264
+ </>
265
+ ) : (
266
+ <>
267
+ <i className="bi bi-cloud-download me-1"></i>
268
+ {device.deviceId.substring(0, 8)}...
269
+ <span className="badge bg-secondary ms-1">{Math.round(device.latencyMs)}ms</span>
270
+ </>
271
+ )}
272
+ </button>
273
+ ))}
274
+ </div>
275
+ </div>
276
+ ) : (
277
+ <div className="alert alert-info mb-0">
278
+ <i className="bi bi-info-circle me-2"></i>
279
+ No devices are currently connected to this group. Connect to a device first to download its database.
280
+ </div>
281
+ )}
282
+
283
+ {downloadProgress && (
284
+ <div className="mt-3">
285
+ <div className="d-flex align-items-center">
286
+ <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
287
+ <span>{downloadProgress}</span>
288
+ </div>
289
+ </div>
290
+ )}
291
+
292
+ {downloadResult && (
293
+ <div className={`alert mt-3 mb-0 ${downloadResult.success ? 'alert-success' : 'alert-danger'}`}>
294
+ {downloadResult.success ? (
295
+ <>
296
+ <i className="bi bi-check-circle me-2"></i>
297
+ <strong>Download complete!</strong>
298
+ <br />
299
+ <small>
300
+ File: <code>{downloadResult.filePath}</code>
301
+ <br />
302
+ Size: {downloadResult.size ? `${(downloadResult.size / (1024 * 1024)).toFixed(2)} MB` : 'Unknown'}
303
+ </small>
304
+ </>
305
+ ) : (
306
+ <>
307
+ <i className="bi bi-exclamation-circle me-2"></i>
308
+ <strong>Download failed:</strong> {downloadResult.message}
309
+ </>
310
+ )}
311
+ </div>
312
+ )}
313
+ </div>
171
314
  </>
172
315
  )}
173
316
  </div>
@@ -8,6 +8,25 @@ import { DeviceDetailsModal } from './device-details-modal';
8
8
  import { GroupDetailsModal } from './group-details-modal';
9
9
  import { UsageGraph } from './usage-graph';
10
10
 
11
+ /** Format bytes to human-readable string (KB, MB, GB) */
12
+ function formatBytes(bytes: number): string {
13
+ if (bytes < 1024) return `${bytes} B`;
14
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
15
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
16
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
17
+ }
18
+
19
+ /** Format transfer rate to human-readable string (KB/s or MB/s) */
20
+ function formatRate(mbps: number | undefined): string {
21
+ if (mbps === undefined || mbps === 0) return '0 KB/s';
22
+ // Show KB/s if less than 0.1 MB/s (100 KB/s)
23
+ if (mbps < 0.1) {
24
+ const kbps = mbps * 1024;
25
+ return `${kbps.toFixed(1)} KB/s`;
26
+ }
27
+ return `${mbps.toFixed(2)} MB/s`;
28
+ }
29
+
11
30
  // TypeScript type definitions (matching backend)
12
31
  interface INetworkOverview {
13
32
  localDeviceId: string;
@@ -43,6 +62,11 @@ interface IConnectionInfo {
43
62
  errorRate: number;
44
63
  isServer: boolean;
45
64
  connectionAddress?: string;
65
+ // Throughput stats
66
+ bytesSent?: number;
67
+ bytesReceived?: number;
68
+ sendRateMBps?: number;
69
+ receiveRateMBps?: number;
46
70
  }
47
71
 
48
72
  interface IDeviceConnection {
@@ -329,35 +353,6 @@ export function NetworkViewerList() {
329
353
  </div>
330
354
  </div>
331
355
 
332
- {/* Groups Summary */}
333
- {overview.groups.length > 0 && (
334
- <div className="card mb-4">
335
- <div className="card-body">
336
- <h5 className="card-title">Groups: {overview.groups.length}</h5>
337
- <div className="table-responsive">
338
- <table className="table table-sm">
339
- <thead>
340
- <tr>
341
- <th>Group Name</th>
342
- <th>Connected Devices</th>
343
- <th>Total Members</th>
344
- </tr>
345
- </thead>
346
- <tbody>
347
- {overview.groups.map(group => (
348
- <tr key={group.groupId}>
349
- <td>{group.groupName}</td>
350
- <td>{group.connectedDevices}</td>
351
- <td>{group.totalMembers}</td>
352
- </tr>
353
- ))}
354
- </tbody>
355
- </table>
356
- </div>
357
- </div>
358
- </div>
359
- )}
360
-
361
356
  {/* Connections List */}
362
357
  <div className="card mb-4">
363
358
  <div className="card-body">
@@ -379,6 +374,7 @@ export function NetworkViewerList() {
379
374
  <th>Shared Groups</th>
380
375
  <th>Latency</th>
381
376
  <th>Error Rate</th>
377
+ <th>Throughput</th>
382
378
  {/* <th>State</th> */}
383
379
  <th>Actions</th>
384
380
  </tr>
@@ -451,6 +447,17 @@ export function NetworkViewerList() {
451
447
  {(conn.errorRate * 100).toFixed(1)}%
452
448
  </span>
453
449
  </td>
450
+ <td>
451
+ <small>
452
+ <span className="text-success">↑{formatRate(conn.sendRateMBps)}</span>
453
+ {' / '}
454
+ <span className="text-primary">↓{formatRate(conn.receiveRateMBps)}</span>
455
+ </small>
456
+ <br />
457
+ <small className="text-muted">
458
+ {formatBytes(conn.bytesSent || 0)} / {formatBytes(conn.bytesReceived || 0)}
459
+ </small>
460
+ </td>
454
461
  {/* <td>
455
462
  <span className="badge bg-success">{conn.connectionState}</span>
456
463
  </td> */}
@@ -1,9 +1,9 @@
1
- import React, { useEffect, useState } from 'react';
2
- import { packagesRootDir, reloadPackagesOnPageRefresh, rpcServerCalls, Devices, thisDeviceId, IDevice, getUserContext } from "@peers-app/peers-sdk";
3
- import { Tooltip } from '../../components/tooltip';
4
- import { ColorModeDropdown } from './color-mode-dropdown';
1
+ import { Devices, getUserContext, IDevice, packagesRootDir, reloadPackagesOnPageRefresh, rpcServerCalls, thisDeviceId, TrustLevel } from "@peers-app/peers-sdk";
2
+ import React, { useEffect } from 'react';
5
3
  import { Input } from '../../components/input';
4
+ import { Tooltip } from '../../components/tooltip';
6
5
  import { useObservable, useObservableState } from '../../hooks';
6
+ import { ColorModeDropdown } from './color-mode-dropdown';
7
7
 
8
8
  export const SettingsPage: React.FC = () => {
9
9
  return (
@@ -39,11 +39,19 @@ const DeviceName: React.FC = () => {
39
39
 
40
40
  const handleSave = async () => {
41
41
  try {
42
- if (!deviceId) return;
42
+ if (!deviceId) {
43
+ console.error('No device id');
44
+ return;
45
+ }
43
46
  const userContext = await getUserContext();
44
47
  const devicesTable = Devices(userContext.userDataContext);
45
- const device = await devicesTable.get(deviceId);
46
- if (!device) return;
48
+ const device: IDevice = await devicesTable.get(deviceId) || {
49
+ deviceId,
50
+ userId: userContext.userId,
51
+ firstSeen: new Date(),
52
+ lastSeen: new Date(),
53
+ trustLevel: TrustLevel.NewDevice,
54
+ };
47
55
  device.name = deviceName() || undefined;
48
56
  await devicesTable.save(device);
49
57
  currentDeviceName(deviceName());
@@ -69,8 +77,8 @@ const DeviceName: React.FC = () => {
69
77
  />
70
78
  <button
71
79
  className='btn btn-primary btn-sm'
72
- onClick={handleSave}
73
- disabled={currentDeviceName() === deviceName()}
80
+ onClick={handleSave}
81
+ disabled={currentDeviceName() === deviceName()}
74
82
  >
75
83
  Save
76
84
  </button>
@@ -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