@peers-app/peers-ui 0.19.10 → 0.19.13
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/README.md +7 -2
- package/dist/components/group-switcher.js +3 -4
- package/dist/components/voice-playback.d.ts +15 -0
- package/dist/components/voice-playback.js +86 -0
- package/dist/components/voice-subscribe-events.d.ts +0 -4
- package/dist/index.d.ts +10 -2
- package/dist/index.js +23 -2
- package/dist/operator-console/operator-console-state.d.ts +49 -0
- package/dist/operator-console/operator-console-state.js +97 -0
- package/dist/operator-console/operator-console.d.ts +32 -0
- package/dist/operator-console/operator-console.js +88 -0
- package/dist/root-layout/global-overlays.d.ts +37 -0
- package/dist/root-layout/global-overlays.js +37 -0
- package/dist/root-layout/layout-picker.d.ts +9 -0
- package/dist/root-layout/layout-picker.js +22 -0
- package/dist/root-layout/layouts/full-screen-layout.d.ts +12 -0
- package/dist/root-layout/layouts/full-screen-layout.js +60 -0
- package/dist/root-layout/layouts/tabs-with-console-layout.d.ts +20 -0
- package/dist/root-layout/layouts/tabs-with-console-layout.js +158 -0
- package/dist/root-layout/root-layout-host.d.ts +19 -0
- package/dist/root-layout/root-layout-host.js +88 -0
- package/dist/root-layout/root-layout-registry.d.ts +15 -0
- package/dist/root-layout/root-layout-registry.js +50 -0
- package/dist/root-layout/root-layout-state.d.ts +23 -0
- package/dist/root-layout/root-layout-state.js +54 -0
- package/dist/root-layout/root-layout.d.ts +30 -0
- package/dist/root-layout/root-layout.js +12 -0
- package/dist/screens/network-viewer/connection-troubleshooter.js +7 -11
- package/dist/screens/settings/settings-page.js +2 -1
- package/dist/screens/setup-user.js +2 -7
- package/dist/tabs-layout/tabs-layout.d.ts +48 -4
- package/dist/tabs-layout/tabs-layout.js +27 -53
- package/dist/tabs-layout/tabs-state.d.ts +10 -0
- package/dist/tabs-layout/tabs-state.js +33 -1
- package/package.json +3 -3
- package/src/components/group-switcher.tsx +5 -7
- package/src/components/voice-playback.tsx +97 -0
- package/src/components/voice-subscribe-events.ts +0 -4
- package/src/index.tsx +24 -2
- package/src/operator-console/operator-console-state.ts +119 -0
- package/src/operator-console/operator-console.tsx +233 -0
- package/src/root-layout/global-overlays.ts +50 -0
- package/src/root-layout/layout-picker.tsx +47 -0
- package/src/root-layout/layouts/full-screen-layout.tsx +144 -0
- package/src/root-layout/layouts/tabs-with-console-layout.tsx +260 -0
- package/src/root-layout/root-layout-host.tsx +113 -0
- package/src/root-layout/root-layout-registry.ts +51 -0
- package/src/root-layout/root-layout-state.ts +55 -0
- package/src/root-layout/root-layout.ts +32 -0
- package/src/screens/network-viewer/connection-troubleshooter.tsx +10 -14
- package/src/screens/settings/settings-page.tsx +7 -1
- package/src/screens/setup-user.tsx +2 -9
- package/src/tabs-layout/tabs-layout.tsx +28 -74
- package/src/tabs-layout/tabs-state.ts +31 -0
- package/dist/components/chat-overlay.d.ts +0 -14
- package/dist/components/chat-overlay.js +0 -477
- package/src/components/chat-overlay.tsx +0 -854
|
@@ -1,4 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import React, { Component } from "react";
|
|
2
|
+
import { type TabState } from "./tabs-state";
|
|
3
|
+
interface TabErrorBoundaryProps {
|
|
4
|
+
tabTitle: string;
|
|
5
|
+
tabId: string;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
interface TabErrorBoundaryState {
|
|
9
|
+
hasError: boolean;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Wraps a single tab's content so a crash in one tab doesn't take down the whole app.
|
|
14
|
+
* Exported so other root layouts (e.g. the full-screen layout) can render a tab body with
|
|
15
|
+
* the same crash isolation. Reset it across screens by giving it a `key`.
|
|
16
|
+
*/
|
|
17
|
+
export declare class TabErrorBoundary extends Component<TabErrorBoundaryProps, TabErrorBoundaryState> {
|
|
18
|
+
constructor(props: TabErrorBoundaryProps);
|
|
19
|
+
static getDerivedStateFromError(error: Error): TabErrorBoundaryState;
|
|
20
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void;
|
|
21
|
+
render(): string | number | boolean | Iterable<React.ReactNode> | import("react/jsx-runtime").JSX.Element | null | undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The tab UI body: the tab strip plus the active tab's content. Rendered as the body of
|
|
25
|
+
* the default `tabs` root layout inside `RootLayoutHost` (which owns bootstrapping, the
|
|
26
|
+
* command palette, and the welcome modal). Branches on `isDesktop` for mobile vs. desktop
|
|
27
|
+
* chrome.
|
|
28
|
+
*
|
|
29
|
+
* @param fillContainer When `true`, the body fills its parent (`height: 100%`) instead of
|
|
30
|
+
* sizing itself to the viewport. Set this when embedding the tabs UI inside another
|
|
31
|
+
* layout that owns the vertical space (e.g. the `tabs-with-console` split layout);
|
|
32
|
+
* leave it `false`/unset for the standalone `tabs` layout, which sizes to the viewport.
|
|
33
|
+
*/
|
|
34
|
+
export declare function TabsLayoutInternal({ fillContainer }?: {
|
|
35
|
+
fillContainer?: boolean;
|
|
36
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
37
|
+
interface TabContentProps {
|
|
38
|
+
tab: TabState;
|
|
39
|
+
isMobile: boolean;
|
|
40
|
+
isActive: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Renders a single tab's body, lazily initializing it on first activation and keeping it
|
|
44
|
+
* mounted thereafter (`initializedTabs`). Exported so other root layouts (e.g. full-screen)
|
|
45
|
+
* can render the active screen through the same routing/launcher path.
|
|
46
|
+
*/
|
|
47
|
+
export declare function TabContent({ tab, isMobile, isActive }: TabContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
48
|
+
export {};
|
|
@@ -33,24 +33,27 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.
|
|
37
|
-
exports.
|
|
36
|
+
exports.TabErrorBoundary = void 0;
|
|
37
|
+
exports.TabsLayoutInternal = TabsLayoutInternal;
|
|
38
|
+
exports.TabContent = TabContent;
|
|
38
39
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
39
40
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
40
41
|
const react_1 = __importStar(require("react"));
|
|
41
42
|
const command_palette_1 = require("../command-palette/command-palette");
|
|
42
|
-
const command_palette_ui_1 = require("../command-palette/command-palette-ui");
|
|
43
43
|
const group_switcher_1 = require("../components/group-switcher");
|
|
44
44
|
const router_1 = require("../components/router");
|
|
45
45
|
const globals_1 = require("../globals");
|
|
46
46
|
const hooks_1 = require("../hooks");
|
|
47
47
|
const color_mode_dropdown_1 = require("../screens/settings/color-mode-dropdown");
|
|
48
|
-
const setup_user_1 = require("../screens/setup-user");
|
|
49
|
-
const welcome_modal_1 = require("../screens/welcome-modal");
|
|
50
48
|
const system_apps_1 = require("../system-apps");
|
|
51
49
|
const routes_loader_1 = require("../ui-router/routes-loader");
|
|
52
50
|
const ui_loader_1 = require("../ui-router/ui-loader");
|
|
53
51
|
const tabs_state_1 = require("./tabs-state");
|
|
52
|
+
/**
|
|
53
|
+
* Wraps a single tab's content so a crash in one tab doesn't take down the whole app.
|
|
54
|
+
* Exported so other root layouts (e.g. the full-screen layout) can render a tab body with
|
|
55
|
+
* the same crash isolation. Reset it across screens by giving it a `key`.
|
|
56
|
+
*/
|
|
54
57
|
class TabErrorBoundary extends react_1.Component {
|
|
55
58
|
constructor(props) {
|
|
56
59
|
super(props);
|
|
@@ -74,53 +77,19 @@ class TabErrorBoundary extends react_1.Component {
|
|
|
74
77
|
return this.props.children;
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return (0, jsx_runtime_1.jsx)(TabsLayout, { userId: userId });
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
let userContextInitialized = false;
|
|
94
|
-
function TabsLayout(props) {
|
|
95
|
-
if (!userContextInitialized) {
|
|
96
|
-
(0, peers_sdk_1.setDefaultClientUserContext)(props.userId);
|
|
97
|
-
userContextInitialized = true;
|
|
98
|
-
}
|
|
99
|
-
const loaded = (0, hooks_1.usePromise)(async () => {
|
|
100
|
-
await (0, globals_1.loadGlobals)();
|
|
101
|
-
await (0, routes_loader_1.loadAllRoutes)();
|
|
102
|
-
await Promise.all([
|
|
103
|
-
tabs_state_1.activeTabs.loadingPromise,
|
|
104
|
-
tabs_state_1.activeTabId.loadingPromise,
|
|
105
|
-
tabs_state_1.recentlyUsedApps.loadingPromise,
|
|
106
|
-
peers_sdk_1.hasShownWelcomeModal.loadingPromise,
|
|
107
|
-
]);
|
|
108
|
-
return true;
|
|
109
|
-
});
|
|
110
|
-
const [showWelcomeModal, setShowWelcomeModal] = (0, react_1.useState)(false);
|
|
111
|
-
const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
|
|
112
|
-
// Check if we should show the welcome modal after loading
|
|
113
|
-
(0, react_1.useEffect)(() => {
|
|
114
|
-
if (loaded && !(0, peers_sdk_1.hasShownWelcomeModal)()) {
|
|
115
|
-
setShowWelcomeModal(true);
|
|
116
|
-
}
|
|
117
|
-
}, [loaded]);
|
|
118
|
-
if (!loaded)
|
|
119
|
-
return false;
|
|
120
|
-
document.getElementById("appLoadingDiv")?.remove();
|
|
121
|
-
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(TabsLayoutInternal, {}), (0, jsx_runtime_1.jsx)(command_palette_ui_1.CommandPaletteOverlay, {}), showWelcomeModal && ((0, jsx_runtime_1.jsx)(welcome_modal_1.WelcomeModal, { colorMode: _colorMode, onClose: () => setShowWelcomeModal(false) }))] }));
|
|
122
|
-
}
|
|
123
|
-
function TabsLayoutInternal() {
|
|
80
|
+
exports.TabErrorBoundary = TabErrorBoundary;
|
|
81
|
+
/**
|
|
82
|
+
* The tab UI body: the tab strip plus the active tab's content. Rendered as the body of
|
|
83
|
+
* the default `tabs` root layout inside `RootLayoutHost` (which owns bootstrapping, the
|
|
84
|
+
* command palette, and the welcome modal). Branches on `isDesktop` for mobile vs. desktop
|
|
85
|
+
* chrome.
|
|
86
|
+
*
|
|
87
|
+
* @param fillContainer When `true`, the body fills its parent (`height: 100%`) instead of
|
|
88
|
+
* sizing itself to the viewport. Set this when embedding the tabs UI inside another
|
|
89
|
+
* layout that owns the vertical space (e.g. the `tabs-with-console` split layout);
|
|
90
|
+
* leave it `false`/unset for the standalone `tabs` layout, which sizes to the viewport.
|
|
91
|
+
*/
|
|
92
|
+
function TabsLayoutInternal({ fillContainer = false } = {}) {
|
|
124
93
|
const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
|
|
125
94
|
const [tabs] = (0, hooks_1.useObservable)(tabs_state_1.activeTabs);
|
|
126
95
|
const [activeTab] = (0, hooks_1.useObservable)(tabs_state_1.activeTabId);
|
|
@@ -170,7 +139,7 @@ function TabsLayoutInternal() {
|
|
|
170
139
|
const switchTab = (tabId) => {
|
|
171
140
|
(0, tabs_state_1.activeTabId)(tabId);
|
|
172
141
|
};
|
|
173
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-column", style: { height: "calc(100vh - 25px)", overflow: "hidden" }, children: [switchingGroup && ((0, jsx_runtime_1.jsx)("div", { style: {
|
|
142
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-column", style: { height: fillContainer ? "100%" : "calc(100vh - 25px)", overflow: "hidden" }, children: [switchingGroup && ((0, jsx_runtime_1.jsx)("div", { style: {
|
|
174
143
|
position: "fixed",
|
|
175
144
|
inset: 0,
|
|
176
145
|
zIndex: 9999,
|
|
@@ -284,6 +253,11 @@ function TabHeader({ tab, isActive, onSwitch, onClose, colorMode }) {
|
|
|
284
253
|
onClose(tab.tabId);
|
|
285
254
|
}, children: (0, jsx_runtime_1.jsx)("i", { className: "bi-x" }) }))] }));
|
|
286
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Renders a single tab's body, lazily initializing it on first activation and keeping it
|
|
258
|
+
* mounted thereafter (`initializedTabs`). Exported so other root layouts (e.g. full-screen)
|
|
259
|
+
* can render the active screen through the same routing/launcher path.
|
|
260
|
+
*/
|
|
287
261
|
function TabContent({ tab, isMobile, isActive }) {
|
|
288
262
|
// Only render content if this tab is active OR has been previously initialized
|
|
289
263
|
const shouldRender = isActive || tabs_state_1.initializedTabs.has(tab.tabId);
|
|
@@ -26,6 +26,16 @@ type AppInfo = {
|
|
|
26
26
|
};
|
|
27
27
|
export declare function determineAppFromPath(path: string): AppInfo | undefined;
|
|
28
28
|
export declare const updateActiveTabTitle: (newTitle: string) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Enable or disable single-tab mode.
|
|
31
|
+
*
|
|
32
|
+
* When enabled, {@link openNewTab} replaces the active (non-launcher) screen in place
|
|
33
|
+
* rather than appending a new tab, so single-screen layouts (e.g. full-screen) never
|
|
34
|
+
* accumulate tabs. This is non-destructive: any pre-existing tabs are left untouched and
|
|
35
|
+
* reappear when a multi-tab layout becomes active again. Layouts should enable it on mount
|
|
36
|
+
* and disable it on unmount.
|
|
37
|
+
*/
|
|
38
|
+
export declare const setSingleTabMode: (enabled: boolean) => void;
|
|
29
39
|
export declare const openNewTab: (tab: Omit<TabState, "tabId">, forceNew?: boolean) => void;
|
|
30
40
|
export declare const closeCurrentTab: () => void;
|
|
31
41
|
export declare const switchToNextTab: () => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.switchToPreviousTab = exports.switchToNextTab = exports.closeCurrentTab = exports.openNewTab = exports.updateActiveTabTitle = exports.handleMainPathChanged = exports.initializedTabs = exports.recentlyUsedApps = exports.activeTabId = exports.activeTabs = exports.launcherApp = void 0;
|
|
3
|
+
exports.switchToPreviousTab = exports.switchToNextTab = exports.closeCurrentTab = exports.openNewTab = exports.setSingleTabMode = exports.updateActiveTabTitle = exports.handleMainPathChanged = exports.initializedTabs = exports.recentlyUsedApps = exports.activeTabId = exports.activeTabs = exports.launcherApp = void 0;
|
|
4
4
|
exports.goToTabPath = goToTabPath;
|
|
5
5
|
exports.determineAppFromPath = determineAppFromPath;
|
|
6
6
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
@@ -206,6 +206,22 @@ const updateActiveTabTitle = (newTitle) => {
|
|
|
206
206
|
}, 100);
|
|
207
207
|
};
|
|
208
208
|
exports.updateActiveTabTitle = updateActiveTabTitle;
|
|
209
|
+
// Single-tab mode: when enabled, opening an app replaces the current screen instead of
|
|
210
|
+
// appending a tab. Used by the full-screen root layout so it shows one screen at a time.
|
|
211
|
+
let singleTabModeEnabled = false;
|
|
212
|
+
/**
|
|
213
|
+
* Enable or disable single-tab mode.
|
|
214
|
+
*
|
|
215
|
+
* When enabled, {@link openNewTab} replaces the active (non-launcher) screen in place
|
|
216
|
+
* rather than appending a new tab, so single-screen layouts (e.g. full-screen) never
|
|
217
|
+
* accumulate tabs. This is non-destructive: any pre-existing tabs are left untouched and
|
|
218
|
+
* reappear when a multi-tab layout becomes active again. Layouts should enable it on mount
|
|
219
|
+
* and disable it on unmount.
|
|
220
|
+
*/
|
|
221
|
+
const setSingleTabMode = (enabled) => {
|
|
222
|
+
singleTabModeEnabled = enabled;
|
|
223
|
+
};
|
|
224
|
+
exports.setSingleTabMode = setSingleTabMode;
|
|
209
225
|
// Global function to open content in new tab
|
|
210
226
|
const openNewTab = (tab, forceNew = false) => {
|
|
211
227
|
const newTab = {
|
|
@@ -218,6 +234,22 @@ const openNewTab = (tab, forceNew = false) => {
|
|
|
218
234
|
// Switch to existing tab
|
|
219
235
|
(0, exports.activeTabId)(currentTabs[existingTabIndex].tabId);
|
|
220
236
|
}
|
|
237
|
+
else if (singleTabModeEnabled) {
|
|
238
|
+
// Single-tab mode: replace the active non-launcher screen in place. From the launcher
|
|
239
|
+
// (home), append once so the launcher stays put and the new app becomes the single
|
|
240
|
+
// app slot.
|
|
241
|
+
const activeIndex = currentTabs.findIndex((t) => t.tabId === (0, exports.activeTabId)());
|
|
242
|
+
const canReplace = activeIndex >= 0 && currentTabs[activeIndex].tabId !== "launcher";
|
|
243
|
+
if (canReplace) {
|
|
244
|
+
const newTabs = [...currentTabs];
|
|
245
|
+
newTabs[activeIndex] = newTab;
|
|
246
|
+
(0, exports.activeTabs)(newTabs);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
(0, exports.activeTabs)([...currentTabs, newTab]);
|
|
250
|
+
}
|
|
251
|
+
(0, exports.activeTabId)(newTab.tabId);
|
|
252
|
+
}
|
|
221
253
|
else {
|
|
222
254
|
// Always add new tab (for app launcher or when forceNew is true)
|
|
223
255
|
(0, exports.activeTabs)([...currentTabs, newTab]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.13",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.19.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.19.13",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-env": "^7.24.5",
|
|
40
40
|
"@babel/preset-react": "^7.24.1",
|
|
41
41
|
"@babel/preset-typescript": "^7.27.1",
|
|
42
|
-
"@peers-app/peers-sdk": "0.19.
|
|
42
|
+
"@peers-app/peers-sdk": "0.19.13",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
type IGroup,
|
|
6
6
|
newid,
|
|
7
7
|
newKeys,
|
|
8
|
-
peersCorePackageId,
|
|
9
8
|
rpcServerCalls,
|
|
10
9
|
setUserTrustLevel,
|
|
11
10
|
TrustLevel,
|
|
@@ -293,12 +292,11 @@ function CreateGroupModal({
|
|
|
293
292
|
|
|
294
293
|
await Groups(groupDataContext).signAndSave(newGroup);
|
|
295
294
|
|
|
296
|
-
// Auto-install peers-core for the new group
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
.
|
|
300
|
-
|
|
301
|
-
});
|
|
295
|
+
// Auto-install peers-core for the new group (copy from another context that
|
|
296
|
+
// already has it, else fall back to the remote updateUrl / bundle).
|
|
297
|
+
await rpcServerCalls.seedBundledPeersCore(groupId).catch((err: unknown) => {
|
|
298
|
+
console.error("Error auto-installing peers-core for group:", err);
|
|
299
|
+
});
|
|
302
300
|
|
|
303
301
|
// Notify parent
|
|
304
302
|
onGroupCreated(newGroup.groupId);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice Playback Component
|
|
3
|
+
*
|
|
4
|
+
* Headless (renders nothing) component that hosts the browser-side voice TTS
|
|
5
|
+
* playback handlers. The voice server (peers-electron `tts-service`) emits
|
|
6
|
+
* `voice:speakText` / `voice:playAudio` / `voice:stopSpeaking` events and relies
|
|
7
|
+
* on a mounted renderer to actually produce sound and report completion via
|
|
8
|
+
* `voiceNotifyPlaybackComplete`. Mount this once near the app root so voice
|
|
9
|
+
* output works regardless of which (if any) voice UI is visible.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { rpcServerCalls, subscribe } from "@peers-app/peers-sdk";
|
|
13
|
+
import { useEffect, useRef } from "react";
|
|
14
|
+
import type {
|
|
15
|
+
VoicePlayAudioPayload,
|
|
16
|
+
VoiceSpeakPayload,
|
|
17
|
+
VoiceSubscribeEvent,
|
|
18
|
+
} from "./voice-subscribe-events";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Subscribes to voice TTS events and plays them in the browser. Renders nothing.
|
|
22
|
+
*/
|
|
23
|
+
export const VoicePlayback: React.FC = () => {
|
|
24
|
+
// Track the current cloud-TTS audio element so it can be stopped on demand.
|
|
25
|
+
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const speakHandler = subscribe(
|
|
29
|
+
"voice:speakText",
|
|
30
|
+
(event: VoiceSubscribeEvent<VoiceSpeakPayload>) => {
|
|
31
|
+
const { text, voice, rate } = event.data;
|
|
32
|
+
if ("speechSynthesis" in window) {
|
|
33
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
34
|
+
if (voice) {
|
|
35
|
+
const voices = speechSynthesis.getVoices();
|
|
36
|
+
const selectedVoice = voices.find((v) => v.name === voice);
|
|
37
|
+
if (selectedVoice) utterance.voice = selectedVoice;
|
|
38
|
+
}
|
|
39
|
+
if (rate) utterance.rate = rate;
|
|
40
|
+
utterance.onend = () => {
|
|
41
|
+
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
42
|
+
};
|
|
43
|
+
utterance.onerror = () => {
|
|
44
|
+
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
45
|
+
};
|
|
46
|
+
speechSynthesis.speak(utterance);
|
|
47
|
+
} else {
|
|
48
|
+
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const stopHandler = subscribe("voice:stopSpeaking", () => {
|
|
54
|
+
if ("speechSynthesis" in window) {
|
|
55
|
+
speechSynthesis.cancel();
|
|
56
|
+
}
|
|
57
|
+
if (currentAudioRef.current) {
|
|
58
|
+
currentAudioRef.current.pause();
|
|
59
|
+
currentAudioRef.current = null;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const playHandler = subscribe(
|
|
64
|
+
"voice:playAudio",
|
|
65
|
+
async (event: VoiceSubscribeEvent<VoicePlayAudioPayload>) => {
|
|
66
|
+
const { audioBase64, mimeType } = event.data;
|
|
67
|
+
try {
|
|
68
|
+
const audioData = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
|
|
69
|
+
const blob = new Blob([audioData], { type: mimeType });
|
|
70
|
+
const url = URL.createObjectURL(blob);
|
|
71
|
+
const audio = new Audio(url);
|
|
72
|
+
currentAudioRef.current = audio;
|
|
73
|
+
audio.onended = () => {
|
|
74
|
+
URL.revokeObjectURL(url);
|
|
75
|
+
currentAudioRef.current = null;
|
|
76
|
+
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
77
|
+
};
|
|
78
|
+
await audio.play();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error("Failed to play audio:", e);
|
|
81
|
+
currentAudioRef.current = null;
|
|
82
|
+
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
speakHandler.unsubscribe();
|
|
89
|
+
stopHandler.unsubscribe();
|
|
90
|
+
playHandler.unsubscribe();
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export default VoicePlayback;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import type { IMessage } from "@peers-app/peers-sdk";
|
|
2
|
-
|
|
3
1
|
/** Shape passed to `subscribe` handlers for voice / chat IPC events */
|
|
4
2
|
export type VoiceSubscribeEvent<T> = { data: T };
|
|
5
3
|
|
|
6
|
-
export type ChatOpenWithMessagePayload = { message?: IMessage };
|
|
7
|
-
|
|
8
4
|
export type VoiceStatePayload = { state: string };
|
|
9
5
|
|
|
10
6
|
export type VoiceTranscriptionPayload = { text: string };
|
package/src/index.tsx
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export * from "./components/chat-overlay";
|
|
2
1
|
export * from "./components/inverse-lazy-list";
|
|
3
2
|
export type { IEditorEffects, IMarkdownEditorProps } from "./components/markdown-editor/editor";
|
|
4
3
|
export { MarkdownEditor } from "./components/markdown-editor/editor";
|
|
@@ -8,8 +7,31 @@ export * from "./components/sortable-list";
|
|
|
8
7
|
export * from "./components/tabs";
|
|
9
8
|
export { Typeahead, type TypeaheadItem } from "./components/typeahead";
|
|
10
9
|
export * from "./components/voice-indicator";
|
|
11
|
-
export
|
|
10
|
+
export * from "./components/voice-playback";
|
|
11
|
+
export { isDesktop, mainContentPath } from "./globals";
|
|
12
12
|
export * from "./hooks";
|
|
13
|
+
export * from "./operator-console/operator-console";
|
|
14
|
+
export * from "./operator-console/operator-console-state";
|
|
15
|
+
export {
|
|
16
|
+
type GlobalOverlay,
|
|
17
|
+
globalOverlays,
|
|
18
|
+
registerGlobalOverlay,
|
|
19
|
+
unregisterGlobalOverlay,
|
|
20
|
+
} from "./root-layout/global-overlays";
|
|
21
|
+
export { LayoutPicker } from "./root-layout/layout-picker";
|
|
22
|
+
export type { RootLayout } from "./root-layout/root-layout";
|
|
23
|
+
export {
|
|
24
|
+
RootLayoutHost,
|
|
25
|
+
// Compat alias: the desktop/PWA/RN clients historically mounted `TabsLayoutApp`.
|
|
26
|
+
// The host now owns bootstrapping; the tabs UI is just the default layout body.
|
|
27
|
+
RootLayoutHost as TabsLayoutApp,
|
|
28
|
+
} from "./root-layout/root-layout-host";
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_ROOT_LAYOUT_ID,
|
|
31
|
+
getRootLayout,
|
|
32
|
+
getRootLayouts,
|
|
33
|
+
} from "./root-layout/root-layout-registry";
|
|
34
|
+
export { selectedRootLayoutId, setRootLayout } from "./root-layout/root-layout-state";
|
|
13
35
|
export * from "./tabs-layout/tabs-layout";
|
|
14
36
|
export {
|
|
15
37
|
activeTabId,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Console — panel state
|
|
3
|
+
*
|
|
4
|
+
* Persistent, per-device state for the Operator Console shell plus the control
|
|
5
|
+
* helpers and the global keyboard shortcut that toggles it. The panel itself
|
|
6
|
+
* ({@link OperatorConsolePanel}) reads this state reactively via `useObservable`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { deviceVar } from "@peers-app/peers-sdk";
|
|
10
|
+
import { closeCommandPalette, registerCommand } from "../command-palette/command-palette";
|
|
11
|
+
|
|
12
|
+
/** Edge the console panel is docked to. */
|
|
13
|
+
export type ConsoleDock = "bottom" | "left" | "right";
|
|
14
|
+
|
|
15
|
+
/** Persisted layout/visibility state for the Operator Console panel. */
|
|
16
|
+
export interface ConsolePanelState {
|
|
17
|
+
/** Whether the panel is expanded (open) or collapsed to its edge strip. */
|
|
18
|
+
expanded: boolean;
|
|
19
|
+
/** Which screen edge the panel is docked to. */
|
|
20
|
+
dock: ConsoleDock;
|
|
21
|
+
/** Panel height in pixels when docked to the bottom. */
|
|
22
|
+
bottomHeight: number;
|
|
23
|
+
/** Panel width in pixels when docked to the left or right. */
|
|
24
|
+
sideWidth: number;
|
|
25
|
+
/** Whether the panel is maximized to fill the layout body (hiding the tabs panel). */
|
|
26
|
+
maximized: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Default panel state: collapsed, docked to the bottom. */
|
|
30
|
+
export const DEFAULT_CONSOLE_PANEL_STATE: ConsolePanelState = {
|
|
31
|
+
expanded: false,
|
|
32
|
+
dock: "bottom",
|
|
33
|
+
bottomHeight: 300,
|
|
34
|
+
sideWidth: 420,
|
|
35
|
+
maximized: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Per-device persisted state for the Operator Console panel. Layout/visibility
|
|
40
|
+
* is a per-device preference, so this uses `deviceVar` (not synced across devices).
|
|
41
|
+
*/
|
|
42
|
+
export const operatorConsolePanel = deviceVar<ConsolePanelState>("operatorConsole:panel", {
|
|
43
|
+
defaultValue: DEFAULT_CONSOLE_PANEL_STATE,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function update(patch: Partial<ConsolePanelState>): void {
|
|
47
|
+
const current = operatorConsolePanel() ?? DEFAULT_CONSOLE_PANEL_STATE;
|
|
48
|
+
operatorConsolePanel({ ...current, ...patch });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Toggle the console between expanded and collapsed. */
|
|
52
|
+
export function toggleConsole(): void {
|
|
53
|
+
const current = operatorConsolePanel() ?? DEFAULT_CONSOLE_PANEL_STATE;
|
|
54
|
+
update({ expanded: !current.expanded });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Expand (open) the console. */
|
|
58
|
+
export function openConsole(): void {
|
|
59
|
+
update({ expanded: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Collapse (close) the console. */
|
|
63
|
+
export function closeConsole(): void {
|
|
64
|
+
update({ expanded: false });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Dock the console to the given edge. */
|
|
68
|
+
export function setConsoleDock(dock: ConsoleDock): void {
|
|
69
|
+
update({ dock });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Set whether the console is maximized (fills the layout body, hiding the tabs panel). */
|
|
73
|
+
export function setConsoleMaximized(maximized: boolean): void {
|
|
74
|
+
update({ maximized });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Persist a new panel size. Use `bottomHeight` when docked to the bottom and
|
|
79
|
+
* `sideWidth` when docked to a side.
|
|
80
|
+
*/
|
|
81
|
+
export function setConsoleSize(size: { bottomHeight?: number; sideWidth?: number }): void {
|
|
82
|
+
update(size);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Global keyboard handler for the console toggle shortcut. Ctrl+` on all
|
|
87
|
+
* platforms (matching VS Code / Cursor). Intentionally not Cmd on macOS.
|
|
88
|
+
*/
|
|
89
|
+
function handleKeyDown(event: KeyboardEvent): void {
|
|
90
|
+
if (event.ctrlKey && !event.metaKey && !event.altKey && event.code === "Backquote") {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
toggleConsole();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let initialized = false;
|
|
97
|
+
|
|
98
|
+
/** Install the global Ctrl+` listener and register the palette command (idempotent). */
|
|
99
|
+
export function initializeOperatorConsole(): void {
|
|
100
|
+
if (initialized || typeof window === "undefined") return;
|
|
101
|
+
initialized = true;
|
|
102
|
+
|
|
103
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
104
|
+
|
|
105
|
+
registerCommand({
|
|
106
|
+
id: "toggle-operator-console",
|
|
107
|
+
label: "Toggle Console",
|
|
108
|
+
description: "Show or hide the Operator Console panel",
|
|
109
|
+
shortcut: "Ctrl+`",
|
|
110
|
+
iconClassName: "bi-terminal",
|
|
111
|
+
category: "View",
|
|
112
|
+
action: () => {
|
|
113
|
+
closeCommandPalette();
|
|
114
|
+
toggleConsole();
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
initializeOperatorConsole();
|