Merge branch 'development'
This commit is contained in:
@@ -166,14 +166,26 @@ jobs:
|
||||
# hit on every push after that, so the only thing recompiled per push is
|
||||
# nobilis itself, which is the few minutes that genuinely cannot be
|
||||
# avoided.
|
||||
#
|
||||
# ImageOS is in the key because the runner image is part of what built
|
||||
# these artifacts, and pinning this job to 22.04 changed it without
|
||||
# changing anything the key looked at. The next run restored a tree
|
||||
# compiled on 24.04 under an exact-match key, cargo believed the crates
|
||||
# it named were already built, and the build died on "can't find crate
|
||||
# for tor_bytes" - a dependency whose rlib the restored tree did not
|
||||
# have. The Windows job, still on ubuntu-latest, restored its own
|
||||
# matching cache and passed, which is how the image turned out to be
|
||||
# the variable.
|
||||
- name: cache the cargo target directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: nobilis/target
|
||||
key: cargo-target-linux-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }}
|
||||
key: cargo-target-linux-${{ env.ImageOS }}-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }}
|
||||
# A lockfile bump should start from the previous tree and rebuild
|
||||
# what actually changed, rather than starting from nothing.
|
||||
restore-keys: cargo-target-linux-
|
||||
# what actually changed, rather than starting from nothing. Scoped
|
||||
# to the image for the reason above: falling back across images is
|
||||
# the same trap one step further down.
|
||||
restore-keys: cargo-target-linux-${{ env.ImageOS }}-
|
||||
|
||||
# The Electron binary, and the appimagetool and nsis bundles
|
||||
# electron-builder fetches at pack time. Small, and it removes a real
|
||||
@@ -378,8 +390,11 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: nobilis/target
|
||||
key: cargo-target-windows-gnu-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }}
|
||||
restore-keys: cargo-target-windows-gnu-
|
||||
# Scoped to the image for the same reason as the Linux job's: this
|
||||
# directory also holds host-built artifacts - build scripts and
|
||||
# proc macros - even though the binary itself is cross-compiled.
|
||||
key: cargo-target-windows-gnu-${{ env.ImageOS }}-${{ hashFiles('nobilis/Cargo.lock') }}-${{ env.RUSTC_VERSION }}
|
||||
restore-keys: cargo-target-windows-gnu-${{ env.ImageOS }}-
|
||||
|
||||
- name: cache electron downloads
|
||||
uses: actions/cache@v4
|
||||
|
||||
+1
-1
Submodule nobilis updated: bda039d693...92dffca011
+33
-16
@@ -6,7 +6,6 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
@@ -30,6 +29,7 @@ import { clearnetLinks } from '../shared/clearnet'
|
||||
import { readCapped, pictureNamedIn } from './imagepage'
|
||||
import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink'
|
||||
import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
|
||||
import { installScreenShare } from './screenshare'
|
||||
import { defaultDownloadDir, saveMedia } from './downloads'
|
||||
import type { Buffer as ChatBuffer } from '../shared/wire'
|
||||
import { log } from './log'
|
||||
@@ -273,7 +273,23 @@ function createWindow(): void {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url, frameName }) => {
|
||||
// The one window the page may open itself: a call popped out of the
|
||||
// conversation. The page draws into it directly - see StageWindow - so it
|
||||
// is blank, frameless like this one, and black before anything arrives.
|
||||
if (frameName === 'moho-call' && (url === '' || url === 'about:blank')) {
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
title: 'moho — call',
|
||||
frame: false,
|
||||
backgroundColor: '#000000',
|
||||
minWidth: 360,
|
||||
minHeight: 240,
|
||||
autoHideMenuBar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
@@ -648,20 +664,6 @@ function wireIpc(): void {
|
||||
// far. Separate from pickFile because the two dialogs ask opposite
|
||||
// questions, and a save dialog that cannot name a default file is a save
|
||||
// dialog people cancel.
|
||||
// What could be shared into a call. Thumbnails at a size worth looking at
|
||||
// but not worth waiting for - this is a picker, not a preview.
|
||||
ipcMain.handle(IPC.screenSources, async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: { width: 320, height: 180 }
|
||||
})
|
||||
return sources.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
thumbnail: s.thumbnail.toDataURL()
|
||||
}))
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.pickSavePath, async (e, suggested?: string) => {
|
||||
const parent = callerWindow(e) ?? mainWindow
|
||||
if (!parent) return null
|
||||
@@ -841,6 +843,18 @@ app.whenReady().then(() => {
|
||||
log.toDirectory(path.join(cacheRoot(), 'moho'))
|
||||
log.info('moho starting, logging to', log.file() ?? '(nowhere)')
|
||||
|
||||
// What a call did, from the window that held it. The renderer writes one
|
||||
// `[call] {...}` line per event to its console - see lib/calllog.ts - and
|
||||
// this is the only place they are kept: a console dies with its window, and
|
||||
// the record of why a call had no picture is wanted after the call is over.
|
||||
// Only that prefix, so nothing else a page prints ends up in the file.
|
||||
app.on('web-contents-created', (_e, contents) => {
|
||||
contents.on('console-message', (event) => {
|
||||
const message = (event as unknown as { message?: string }).message
|
||||
if (typeof message === 'string' && message.startsWith('[call] ')) log.info(message)
|
||||
})
|
||||
})
|
||||
|
||||
// Said once, in the log and in the window, and never in the way.
|
||||
//
|
||||
// Chromium's sandbox needs unprivileged user namespaces, and an AppImage
|
||||
@@ -879,6 +893,9 @@ app.whenReady().then(() => {
|
||||
})
|
||||
session.defaultSession.setPermissionCheckHandler((_contents, permission) => allowed.has(permission))
|
||||
|
||||
// Every screen share in the app, on every desktop - see screenshare.ts.
|
||||
installScreenShare(session.defaultSession, () => mainWindow?.webContents)
|
||||
|
||||
// Someone tried to launch a second copy: treat it as "show me moho", which
|
||||
// is almost always what they meant - especially when the window is hidden
|
||||
// to the tray and looks like nothing is running.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { desktopCapturer, ipcMain, webContents, type Session, type WebContents } from 'electron'
|
||||
import { IPC, type ScreenSource } from '../shared/ipc'
|
||||
import { log } from './log'
|
||||
|
||||
/**
|
||||
* Sharing a screen, the same way on every desktop.
|
||||
*
|
||||
* Every share in the app - a Matrix call, a Matrix call on a media server,
|
||||
* a Discord stream - asks the one standard question, `getDisplayMedia`, and
|
||||
* this answers it. What differs between desktops is only who chooses what is
|
||||
* shown, and that is decided here, once, rather than in each place that
|
||||
* shares:
|
||||
*
|
||||
* - **The desktop chooses** on Wayland. Every Wayland compositor - GNOME,
|
||||
* KDE, sway, Hyprland - hands screens out through xdg-desktop-portal, which
|
||||
* always puts up its own "what do you want to share" window and gives an
|
||||
* application no list to draw one of its own from. So the request is
|
||||
* answered straight away and the portal asks.
|
||||
* - **moho chooses** everywhere else: X11, Windows, and macOS before 15. The
|
||||
* screens and windows are listed and moho's own picker is shown in the
|
||||
* window that asked.
|
||||
* - **macOS 15 and later** have a system picker of their own, which Electron
|
||||
* uses instead of calling this at all.
|
||||
*
|
||||
* The rule that must never be broken is that there is exactly one question.
|
||||
* Listing sources on Wayland is not free - it opens a portal session to draw
|
||||
* the thumbnails, which is a portal window of its own - and opening the chosen
|
||||
* source opens a second. That was two windows for one share, and no picture
|
||||
* until the second was answered.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the desktop, rather than moho, chooses what a share shows.
|
||||
*
|
||||
* This has to agree with Chromium, because it is Chromium that decides which
|
||||
* capturer to use, and if the two disagree a share asks twice or not at all.
|
||||
* Chromium's rule (WebRTC's `DesktopCapturer::IsRunningUnderWayland`) is a
|
||||
* Linux session whose XDG_SESSION_TYPE says Wayland *and* which has a Wayland
|
||||
* display to talk to; it captures through the portal whether or not the
|
||||
* window itself is drawn through XWayland. So this is that rule and nothing
|
||||
* else - no compositor, distribution or GPU comes into it.
|
||||
*
|
||||
* `MOHO_SCREENSHARE=picker` or `=portal` overrides it, for a session that
|
||||
* describes itself wrongly.
|
||||
*/
|
||||
export function desktopPicksScreen(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): boolean {
|
||||
if (platform !== 'linux') return false
|
||||
if (env.MOHO_SCREENSHARE === 'picker') return false
|
||||
if (env.MOHO_SCREENSHARE === 'portal') return true
|
||||
return (env.XDG_SESSION_TYPE ?? '').startsWith('wayland') && !!env.WAYLAND_DISPLAY
|
||||
}
|
||||
|
||||
/** Questions put to a window's picker, waiting for its answer. */
|
||||
const waiting = new Map<number, (id: string | null) => void>()
|
||||
let nextQuestion = 1
|
||||
|
||||
/**
|
||||
* Shows the picker in `contents` and waits for a choice.
|
||||
*
|
||||
* Answered with null if the window closes first, so a request is never left
|
||||
* hanging on a picker nobody can see any more.
|
||||
*/
|
||||
function askWindow(contents: WebContents, sources: ScreenSource[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const question = nextQuestion++
|
||||
const gone = (): void => answer(null)
|
||||
const answer = (id: string | null): void => {
|
||||
waiting.delete(question)
|
||||
contents.off('destroyed', gone)
|
||||
resolve(id)
|
||||
}
|
||||
waiting.set(question, answer)
|
||||
contents.once('destroyed', gone)
|
||||
contents.send(IPC.screenPick, question, sources)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers every `getDisplayMedia` in `ses`.
|
||||
*
|
||||
* `fallback` is the window to ask when the request cannot say which one it
|
||||
* came from - a frame that has already navigated away.
|
||||
*/
|
||||
export function installScreenShare(ses: Session, fallback: () => WebContents | undefined): void {
|
||||
ipcMain.on(IPC.screenPicked, (_event, question: number, id: string | null) => {
|
||||
waiting.get(question)?.(typeof id === 'string' ? id : null)
|
||||
})
|
||||
|
||||
const portal = desktopPicksScreen()
|
||||
log.info(`[screenshare] ${portal ? 'the desktop portal chooses what is shared' : 'moho lists screens and windows to choose from'}`)
|
||||
|
||||
ses.setDisplayMediaRequestHandler(
|
||||
(request, callback) => {
|
||||
if (portal) {
|
||||
// Any id will do: the portal capturer ignores it and asks.
|
||||
callback({ video: { id: 'screen:0:0', name: 'Entire screen' } })
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
// A still worth recognising a window by, not worth waiting for.
|
||||
thumbnailSize: { width: 320, height: 180 }
|
||||
})
|
||||
const contents = (request.frame && webContents.fromFrame(request.frame)) || fallback()
|
||||
if (!contents || sources.length === 0) {
|
||||
callback({})
|
||||
return
|
||||
}
|
||||
const chosen = await askWindow(
|
||||
contents,
|
||||
sources.map((s) => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.toDataURL() }))
|
||||
)
|
||||
const source = sources.find((s) => s.id === chosen)
|
||||
// No stream is a refusal, which is what backing out of the picker is.
|
||||
callback(source ? { video: source } : {})
|
||||
} catch (e) {
|
||||
log.warn('[screenshare] could not list what to share:', (e as Error).message)
|
||||
callback({})
|
||||
}
|
||||
})()
|
||||
},
|
||||
// macOS 15+: the system's own picker, and this handler is not called.
|
||||
{ useSystemPicker: true }
|
||||
)
|
||||
}
|
||||
+14
-8
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import { IPC, POPOUT_FLAG, type PopoutState } from '../shared/ipc'
|
||||
import { IPC, POPOUT_FLAG, type PopoutState, type ScreenSource } from '../shared/ipc'
|
||||
import type { NobilisEvent } from '../shared/wire'
|
||||
|
||||
/**
|
||||
@@ -136,15 +136,21 @@ const api = {
|
||||
|
||||
pickFile: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile),
|
||||
/**
|
||||
* What could be shared into a call: every screen and window, each with a
|
||||
* still of what is on it.
|
||||
* Answers the main process when it needs a screen or window chosen.
|
||||
*
|
||||
* Asked of the desktop rather than of the browser. Electron refuses
|
||||
* getDisplayMedia's own picker, and a person choosing what to show a room
|
||||
* needs to see which window they are choosing.
|
||||
* Asked in the middle of a `getDisplayMedia`, on the desktops where moho
|
||||
* draws its own picker - see main/screenshare.ts. The handler resolves with
|
||||
* the chosen source's id, or null for nothing.
|
||||
*/
|
||||
screenSources: (): Promise<{ id: string; name: string; thumbnail: string }[]> =>
|
||||
ipcRenderer.invoke(IPC.screenSources),
|
||||
onScreenPick(cb: (sources: ScreenSource[]) => Promise<string | null>): () => void {
|
||||
const handler = (_e: unknown, question: number, sources: ScreenSource[]): void => {
|
||||
void cb(sources)
|
||||
.catch(() => null)
|
||||
.then((id) => ipcRenderer.send(IPC.screenPicked, question, id))
|
||||
}
|
||||
ipcRenderer.on(IPC.screenPick, handler)
|
||||
return () => ipcRenderer.off(IPC.screenPick, handler)
|
||||
},
|
||||
pickSavePath: (suggested?: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IPC.pickSavePath, suggested),
|
||||
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickDirectory),
|
||||
|
||||
+35
-14
@@ -18,9 +18,11 @@ import { AccountsPanel } from './components/AccountsPanel'
|
||||
import { SettingsPanel } from './components/settings/SettingsPanel'
|
||||
import { DownloadsPanel } from './components/DownloadsPanel'
|
||||
import { JoinPanel } from './components/JoinPanel'
|
||||
import { PeekBar, PeekView } from './components/PeekView'
|
||||
import { Toasts } from './components/Toasts'
|
||||
import { IncomingCallPanel } from './components/IncomingCallPanel'
|
||||
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
|
||||
import { CallAudio, CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
|
||||
import { CallWindowHost } from './components/stage/CallWindowHost'
|
||||
import { StreamStage } from './components/StreamStage'
|
||||
import { DiscordModal } from './components/DiscordModal'
|
||||
import { FileDrop } from './components/FileDrop'
|
||||
@@ -82,6 +84,10 @@ export default function App(): JSX.Element {
|
||||
* composer all belong to a buffer that is not on screen.
|
||||
*/
|
||||
const onMentionsPage = activePanel === '' && activeGroupId === MENTIONS_GROUP_ID
|
||||
// A room being looked into stands where the conversation would. It has no
|
||||
// buffer, so everything below that acts on the open conversation is off.
|
||||
const peek = useChat((s) => s.peek)
|
||||
const peeking = !!peek && activePanel === ''
|
||||
|
||||
// Which Kick channels are actually being watched, told to the daemon as it
|
||||
// changes. Kick counts watch time from an authenticated subscription and
|
||||
@@ -99,7 +105,7 @@ export default function App(): JSX.Element {
|
||||
accounts.find((a) => a.id === allBuffers.find((b) => b.id === id)?.accountId)?.service
|
||||
const now = watchedKickBuffers(
|
||||
allBuffers,
|
||||
activePanel === '' && !onMentionsPage ? activeBufferId : '',
|
||||
activePanel === '' && !onMentionsPage && !peeking ? activeBufferId : '',
|
||||
watching?.bufferId,
|
||||
serviceOf
|
||||
)
|
||||
@@ -113,17 +119,19 @@ export default function App(): JSX.Element {
|
||||
// daemon answers rather than treats as a failure.
|
||||
void window.moho.rpc('setKickWatching', { bufferId, watching: true }).catch(() => {})
|
||||
}
|
||||
}, [booted, allBuffers, accounts, activeBufferId, activePanel, onMentionsPage, watching])
|
||||
}, [booted, allBuffers, accounts, activeBufferId, activePanel, onMentionsPage, peeking, watching])
|
||||
|
||||
const openThread = useChat((s) => s.openThread)
|
||||
|
||||
// A stale restored id is harmless: it simply resolves to no buffer and the
|
||||
// empty state shows, exactly as it would for "".
|
||||
const showNickList =
|
||||
!userListFolded && activePanel === '' && !onMentionsPage && buffer?.kind === 'channel'
|
||||
!userListFolded && activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel'
|
||||
|
||||
const headerTitle =
|
||||
activePanel === 'accounts'
|
||||
peeking
|
||||
? `Looking · ${peek?.name || peek?.alias || ''}`
|
||||
: activePanel === 'accounts'
|
||||
? 'Accounts'
|
||||
: activePanel === 'settings'
|
||||
? 'Settings'
|
||||
@@ -169,7 +177,7 @@ export default function App(): JSX.Element {
|
||||
somewhere you go to find a conversation; this is where you
|
||||
already are when you want to do something to the one you are
|
||||
reading. */}
|
||||
{activePanel === '' && !onMentionsPage && buffer ? (
|
||||
{activePanel === '' && !onMentionsPage && !peeking && buffer ? (
|
||||
<ConversationMenu buffer={buffer} />
|
||||
) : (
|
||||
<span className="main-header-title ellipsis">{headerTitle}</span>
|
||||
@@ -179,7 +187,7 @@ export default function App(): JSX.Element {
|
||||
conversation, which the mentions page is not showing - leaving
|
||||
them there would offer to search a channel that isn't on
|
||||
screen. */}
|
||||
{activePanel === '' && !onMentionsPage && buffer && (
|
||||
{activePanel === '' && !onMentionsPage && !peeking && buffer && (
|
||||
<ConversationTools buffer={buffer} />
|
||||
)}
|
||||
|
||||
@@ -191,7 +199,7 @@ export default function App(): JSX.Element {
|
||||
direct message. */}
|
||||
{activePanel === '' && <MentionsInbox />}
|
||||
|
||||
{activePanel === '' && !onMentionsPage && buffer?.kind === 'channel' && (
|
||||
{activePanel === '' && !onMentionsPage && !peeking && buffer?.kind === 'channel' && (
|
||||
<IconButton
|
||||
name={userListFolded ? 'group' : 'group_off'}
|
||||
title={userListFolded ? 'Show members' : 'Hide members'}
|
||||
@@ -203,12 +211,12 @@ export default function App(): JSX.Element {
|
||||
conversation's own tools: both of these are about how this
|
||||
conversation is being shown, not about the conversation. Last,
|
||||
because it is the one that opens something. */}
|
||||
{activePanel === '' && !onMentionsPage && buffer && <PopOutButton buffer={buffer} />}
|
||||
{activePanel !== '' && (
|
||||
{activePanel === '' && !onMentionsPage && !peeking && buffer && <PopOutButton buffer={buffer} />}
|
||||
{(activePanel !== '' || peeking) && (
|
||||
<IconButton
|
||||
name="close"
|
||||
title="Back to chat"
|
||||
onClick={() => store.setActivePanel('')}
|
||||
title={peeking ? 'Stop looking' : 'Back to chat'}
|
||||
onClick={() => (peeking ? store.stopPeek() : store.setActivePanel(''))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,10 +227,15 @@ export default function App(): JSX.Element {
|
||||
hasAccounts={accounts.length > 0}
|
||||
hasBuffer={!!activeBufferId}
|
||||
activeGroupId={activeGroupId}
|
||||
peeking={peeking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activePanel === '' && activeBufferId !== '' && activeGroupId !== MENTIONS_GROUP_ID && (
|
||||
{/* Where the box would be: what this pane is, and the way in. */}
|
||||
{peeking && <PeekBar />}
|
||||
|
||||
{/* No box while looking: there is nobody here to say it as. */}
|
||||
{activePanel === '' && !peeking && activeBufferId !== '' && activeGroupId !== MENTIONS_GROUP_ID && (
|
||||
<>
|
||||
{/* Above the box, saying why it will not work here yet. */}
|
||||
{buffer && <MembershipGate buffer={buffer} />}
|
||||
@@ -274,6 +287,10 @@ export default function App(): JSX.Element {
|
||||
it belongs to it sits above the log instead - see MessageList - so
|
||||
the two are never both on screen. */}
|
||||
{callElsewhere && <CallStage mode="pip" />}
|
||||
{/* The call's sound, played from here whatever is drawing its pictures,
|
||||
and the window a call can be popped out into. */}
|
||||
<CallAudio />
|
||||
<CallWindowHost />
|
||||
{/* A stream keeps playing when you go and read something else, and goes
|
||||
to the same corner - it is the same surface, differently fed. */}
|
||||
{streamElsewhere && <StreamStage mode="pip" />}
|
||||
@@ -311,12 +328,14 @@ function Body({
|
||||
activePanel,
|
||||
hasAccounts,
|
||||
hasBuffer,
|
||||
activeGroupId
|
||||
activeGroupId,
|
||||
peeking
|
||||
}: {
|
||||
activePanel: string
|
||||
hasAccounts: boolean
|
||||
hasBuffer: boolean
|
||||
activeGroupId: string
|
||||
peeking: boolean
|
||||
}): JSX.Element {
|
||||
if (activePanel === 'settings') return <SettingsPanel />
|
||||
// Above the no-accounts case below: a finished download is still worth
|
||||
@@ -326,6 +345,8 @@ function Body({
|
||||
// show - there is nothing to chat in yet.
|
||||
if (activePanel === 'accounts' || !hasAccounts) return <AccountsPanel />
|
||||
if (activePanel === 'join') return <JoinPanel />
|
||||
// A room being looked into: the pane's own view, with nothing to type into.
|
||||
if (peeking) return <PeekView />
|
||||
// The mentions page replaces the log rather than sitting beside it: it is a
|
||||
// list of places to go, and every row leads into a conversation.
|
||||
if (activeGroupId === MENTIONS_GROUP_ID) return <MentionsPage />
|
||||
|
||||
+367
-201
@@ -2034,136 +2034,6 @@ img.composer-emoji {
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
/* --- the call, while it is happening ------------------------------------ */
|
||||
|
||||
.call-view {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--outline-medium);
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
.call-view-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs) var(--spacing-m);
|
||||
}
|
||||
|
||||
/* The title takes the slack so the buttons stay at the right edge. */
|
||||
.call-view-head > .ellipsis {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-view-head .calling {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.call-tiles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-m);
|
||||
padding: var(--spacing-s) var(--spacing-m) var(--spacing-m);
|
||||
}
|
||||
|
||||
.call-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
width: 88px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The ring, drawn on the picture rather than the tile so it reads as being
|
||||
around the person and not around a box. Outline rather than border: a
|
||||
border would resize the avatar every time somebody started talking, and
|
||||
the whole row would twitch. */
|
||||
.call-tile img,
|
||||
.call-tile .avatar-fallback {
|
||||
outline: 3px solid transparent;
|
||||
outline-offset: 2px;
|
||||
transition: outline-color 0.12s ease;
|
||||
}
|
||||
|
||||
.call-tile.talking img,
|
||||
.call-tile.talking .avatar-fallback {
|
||||
outline-color: var(--success);
|
||||
}
|
||||
|
||||
/* Holds the badges against the picture. */
|
||||
.call-face {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.call-badges {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
/* Its own plate, because these sit over an avatar that could be any
|
||||
colour and a bare glyph would vanish against half of them. */
|
||||
background: var(--surface-container-highest);
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
/* Says a stream exists; deliberately not a button that opens one. */
|
||||
.call-watch {
|
||||
padding: 0 var(--spacing-xs);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--surface-variant-text);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.call-watch[disabled] {
|
||||
opacity: 0.75;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The one being watched, so a row of Live badges says which one is open. */
|
||||
.call-watch.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Somebody else's shared screen, above the row of faces rather than among
|
||||
them: it is the thing being looked at, and a picture sized like an avatar
|
||||
is not worth having. */
|
||||
.discord-stream-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xxs);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.discord-stream-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Height-capped rather than free: the canvas takes its pixel size from the
|
||||
stream, and a 1080p share would otherwise push the roster off the panel.
|
||||
`contain` because the sender chooses the shape and letterboxing a picture
|
||||
is better than stretching somebody's desktop. */
|
||||
.discord-stream-canvas {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* The bar that appears while picking people for a group message. */
|
||||
.group-dm-bar {
|
||||
display: flex;
|
||||
@@ -6800,77 +6670,6 @@ video.lightbox-media {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* --- Looking into a room ---------------------------------------------- */
|
||||
|
||||
/* What is in a room, read from outside it. Sits under the list rather than
|
||||
over it, so the row it came from stays visible - the point of looking is to
|
||||
decide about that room, and a panel covering it takes away what the decision
|
||||
is about. */
|
||||
.room-peek {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
margin: var(--spacing-s) 0;
|
||||
padding: var(--spacing-s);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container-lowest);
|
||||
}
|
||||
|
||||
.room-peek-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.room-peek-head .ellipsis {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-peek-topic {
|
||||
color: var(--surface-variant-text);
|
||||
/* A topic can be a paragraph. Three lines is enough to judge a room by and
|
||||
short enough that the conversation under it stays on screen. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-peek-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.room-peek-line {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.room-peek-from {
|
||||
flex: none;
|
||||
max-width: 12ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--surface-variant-text);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.room-peek-body {
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
/* Somebody else's text, from a room nobody here has joined: it wraps and
|
||||
it never stretches the dialog. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* --- Link previews ---------------------------------------------------- */
|
||||
|
||||
/* The picture a homeserver found behind a link. Capped rather than sized to
|
||||
@@ -7085,3 +6884,370 @@ video.lightbox-media {
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--surface-variant-text);
|
||||
}
|
||||
|
||||
.room-search-suggested { padding: var(--spacing-xs) var(--spacing-m) var(--spacing-s); display: flex; flex-direction: column; gap: var(--spacing-xs); }
|
||||
.room-search-suggested-toggle,
|
||||
.room-search-suggested-all { align-self: flex-start; background: none; border: 0; padding: 0; color: var(--primary); cursor: pointer; font: inherit; }
|
||||
.room-search-suggested-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--spacing-xxs) var(--spacing-m); max-height: 180px; overflow-y: auto; }
|
||||
.room-search-suggestion { display: flex; flex-direction: column; align-items: flex-start; text-align: left; background: none; border: 0; padding: var(--spacing-xxs) 0; color: inherit; cursor: pointer; font: inherit; }
|
||||
.room-search-suggestion:hover .room-search-suggestion-name { text-decoration: underline; }
|
||||
.room-search-suggestion-name { font-weight: 600; }
|
||||
|
||||
/* A room being looked into, in the pane a conversation would fill. */
|
||||
.peek-view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.peek-facts { padding: var(--spacing-xs) var(--spacing-m); border-bottom: 1px solid var(--outline-light); }
|
||||
.peek-bar { flex: none; background: var(--surface-container-high); }
|
||||
.peek-bar-row { display: flex; align-items: center; gap: var(--spacing-s); padding: var(--spacing-s) var(--spacing-m); }
|
||||
.peek-bar-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.peek-topic { padding: var(--spacing-xs) var(--spacing-m); color: var(--surface-variant-text); border-bottom: 1px solid var(--outline-light); }
|
||||
.peek-log { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: var(--spacing-s) 0 var(--spacing-m); }
|
||||
|
||||
/* --- The call stage ---------------------------------------------------- */
|
||||
|
||||
/* A call, drawn the way Discord's client draws one: a black stage above the
|
||||
conversation that looks like a video before there is any video in it.
|
||||
Deliberately the same in both themes - it is a picture, and pictures are
|
||||
shown on black. */
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: none;
|
||||
background: #000;
|
||||
color: #f2f3f5;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.stage.in-window,
|
||||
.stage.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.stage-window-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.stage.collapsed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs) var(--spacing-s);
|
||||
}
|
||||
|
||||
.stage.collapsed .stage-head {
|
||||
position: static;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
height: 44px;
|
||||
padding: 0 var(--spacing-s) 0 var(--spacing-m);
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent);
|
||||
}
|
||||
|
||||
/* A popped-out stage is its window's title bar too: frameless, so the head
|
||||
is what the window is moved by. Buttons stay pressable. */
|
||||
.stage-head.window-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.stage-head.window-drag button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.stage-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-subtitle {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: #b5bac1;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stage-head-buttons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stage-head-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #dbdee1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-head-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stage-area {
|
||||
position: absolute;
|
||||
left: var(--spacing-m);
|
||||
right: var(--spacing-m);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stage-empty {
|
||||
color: #949ba4;
|
||||
}
|
||||
|
||||
.stage-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.stage-focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stage-strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stage-tile {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.stage-tile.strip {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stage-tile:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px #00a8fc;
|
||||
}
|
||||
|
||||
/* Who is talking: the green ring Discord draws. */
|
||||
.stage-tile.speaking {
|
||||
box-shadow: inset 0 0 0 3px #23a55a;
|
||||
}
|
||||
|
||||
.stage-tile-face .avatar-fallback,
|
||||
.stage-tile-face img {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.stage-picture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.stage-tile-name {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: calc(100% - 16px);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stage-tile.strip .stage-tile-name {
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.stage-tile-state {
|
||||
color: #f23f43;
|
||||
}
|
||||
|
||||
.stage-tile-live {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #da373c;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.stage-tile-action {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-tile-action:hover {
|
||||
background: #5865f2;
|
||||
}
|
||||
|
||||
.stage-tile-dismiss {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-tile:hover .stage-tile-dismiss {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The buttons, along the bottom middle. */
|
||||
.stage-controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 14px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stage-controls.compact {
|
||||
position: static;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stage-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #2b2d31;
|
||||
color: #f2f3f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.stage-button.compact {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.stage-button:hover {
|
||||
background: #3a3c42;
|
||||
}
|
||||
|
||||
/* Something switched on that is doing something: a screen being shared. */
|
||||
.stage-button.active {
|
||||
background: #f2f3f5;
|
||||
color: #111214;
|
||||
}
|
||||
|
||||
/* Something switched off that normally is on: a muted microphone. */
|
||||
.stage-button.off {
|
||||
background: #f2f3f5;
|
||||
color: #da373c;
|
||||
}
|
||||
|
||||
.stage-button.danger {
|
||||
background: #da373c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stage-button.danger:hover {
|
||||
background: #a12828;
|
||||
}
|
||||
|
||||
.stage-resize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
height: 6px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.stage-resize:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* The line left above the conversation while the call is in its own window. */
|
||||
.stage-away {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs) var(--spacing-m);
|
||||
background: var(--surface-container-high);
|
||||
border-bottom: 1px solid var(--outline-light);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { VideoStage } from './VideoStage'
|
||||
import { Stage, fitTiles, type StageButton, type StageTile } from './stage/Stage'
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import type { CallTile } from '../state/store'
|
||||
@@ -22,141 +23,159 @@ import { bufferDisplayName, classes } from '../lib/util'
|
||||
* which is what a call is on any service - the screen-share button here is the
|
||||
* same button Discord's will be, and the tiles are the same tiles.
|
||||
*/
|
||||
/** Sixteen by nine, which is what cameras and screens both are. */
|
||||
const TILE_RATIO = 16 / 9
|
||||
/** No tile gets bigger than this, however much room there is. */
|
||||
const TILE_MAX = 420
|
||||
export { fitTiles } from './stage/Stage'
|
||||
|
||||
/** The gap between tiles in the corner player. */
|
||||
const TILE_GAP = 6
|
||||
|
||||
/**
|
||||
* How to lay out a call of this size in the space there is.
|
||||
*
|
||||
* Every column count is tried and the one that makes the tiles biggest wins,
|
||||
* subject to them all fitting - which is what a grid of faces should do and
|
||||
* what a fixed table of counts cannot: three people in a wide short box want
|
||||
* one row, and the same three in a tall narrow one want three.
|
||||
*
|
||||
* Capped, because a call between two people on a large screen should not be
|
||||
* two enormous windows - past a certain size a face stops being easier to
|
||||
* read and the conversation underneath just disappears.
|
||||
*/
|
||||
export function fitTiles(
|
||||
count: number,
|
||||
width: number,
|
||||
height: number,
|
||||
max: number = TILE_MAX
|
||||
): { columns: number; tileWidth: number } {
|
||||
if (count < 1 || width < 1) return { columns: 1, tileWidth: 0 }
|
||||
let best = { columns: 1, tileWidth: 0 }
|
||||
for (let columns = 1; columns <= count; columns++) {
|
||||
const rows = Math.ceil(count / columns)
|
||||
// What the width allows, and what the height allows; the smaller governs.
|
||||
const byWidth = (width - TILE_GAP * (columns - 1)) / columns
|
||||
const byHeight = ((height - TILE_GAP * (rows - 1)) / rows) * TILE_RATIO
|
||||
const tileWidth = Math.min(byWidth, byHeight, max)
|
||||
if (tileWidth > best.tileWidth) best = { columns, tileWidth }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX.Element | null {
|
||||
export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const call = useChat((s) => s.activeCall)
|
||||
const minimized = useChat((s) => s.callMinimized)
|
||||
const poppedOut = useChat((s) => s.callPoppedOut)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const videos = useRef(new Map<string, HTMLVideoElement>())
|
||||
const grid = useRef<HTMLDivElement>(null)
|
||||
const [tiles, setTiles] = useState<CallTile[]>([])
|
||||
/** How wide the grid is, which decides how the tiles are laid out. */
|
||||
const [room, setRoom] = useState({ width: 640, height: 260 })
|
||||
/**
|
||||
* Whose picture is being looked at, if anybody.
|
||||
*
|
||||
* Null is the grid, where nobody is more important than anybody else. Naming
|
||||
* one person is a decision the person watching made, so it is undone the
|
||||
* same way it was made rather than by anything the call does.
|
||||
*/
|
||||
/** How wide the corner player's grid is, which decides its layout. */
|
||||
const [room, setRoom] = useState({ width: 320, height: 180 })
|
||||
/** Whose picture the corner player is showing large, if anybody. */
|
||||
const [focused, setFocused] = useState<string | null>(null)
|
||||
|
||||
// Measured rather than assumed: the same call is a different shape in a
|
||||
// narrow window, in a full-screen one, and in the corner it retreats to.
|
||||
useEffect(() => {
|
||||
const el = grid.current
|
||||
if (!el) return
|
||||
const measure = (): void =>
|
||||
setRoom({
|
||||
width: el.clientWidth,
|
||||
// As much height as a call may take before it is taking over the
|
||||
// conversation rather than sitting above it.
|
||||
height: Math.min(window.innerHeight * (mode === 'pip' ? 0.3 : 0.45), mode === 'pip' ? 260 : 460)
|
||||
})
|
||||
if (!el || mode !== 'pip') return
|
||||
const measure = (): void => setRoom({ width: el.clientWidth, height: Math.min(window.innerHeight * 0.3, 260) })
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
window.addEventListener('resize', measure)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [mode, minimized])
|
||||
|
||||
// The streams are not state: they are live objects whose tracks change under
|
||||
// a call - somebody turning a camera on, somebody starting to share - so the
|
||||
// elements are pointed at them rather than re-rendered from them.
|
||||
// elements are pointed at them rather than re-rendered from them. Every
|
||||
// element showing a person is kept pointed at their stream, however many
|
||||
// places they are drawn at once.
|
||||
useEffect(() => {
|
||||
if (!call) return
|
||||
const attach = (): void => {
|
||||
const current = store.callTiles()
|
||||
setTiles((was) =>
|
||||
was.length === current.length && was.every((t, i) => t.id === current[i].id && t.hasVideo === current[i].hasVideo)
|
||||
was.length === current.length &&
|
||||
was.every(
|
||||
(t, i) =>
|
||||
t.id === current[i].id &&
|
||||
t.hasVideo === current[i].hasVideo &&
|
||||
t.speaking === current[i].speaking &&
|
||||
t.muted === current[i].muted
|
||||
)
|
||||
? was
|
||||
: current
|
||||
)
|
||||
// Both slots: in the focused layout the same person is on the stage and
|
||||
// in the strip underneath, which is two elements showing one stream.
|
||||
for (const tile of current) {
|
||||
for (const slot of ['stage', 'strip']) {
|
||||
const el = videos.current.get(`${tile.id}#${slot}`)
|
||||
if (el && tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream
|
||||
for (const [key, el] of videos.current) {
|
||||
if (key.startsWith(`${tile.id}#`) && tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream
|
||||
}
|
||||
}
|
||||
}
|
||||
attach()
|
||||
const timer = setInterval(attach, 700)
|
||||
const timer = setInterval(attach, 300)
|
||||
return () => clearInterval(timer)
|
||||
}, [call, store])
|
||||
|
||||
if (!call) return null
|
||||
|
||||
// In a window of its own: the conversation keeps a line saying so, and the
|
||||
// corner keeps nothing.
|
||||
if (mode !== 'window' && poppedOut) {
|
||||
if (mode === 'pip') return null
|
||||
return (
|
||||
<div className="stage-away">
|
||||
<Icon name="open_in_new" size={16} />
|
||||
<span className="small">The call is in its own window.</span>
|
||||
<button type="button" className="button subtle" onClick={() => store.setCallPoppedOut(false)}>
|
||||
Bring it back
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const where = buffers.find((b) => b.id === call.bufferId)?.name ?? ''
|
||||
// Somebody who has left the call cannot go on being the one looked at.
|
||||
const stage = tiles.find((t) => t.id === focused) ?? null
|
||||
const layout = fitTiles(Math.max(tiles.length, 1), room.width, room.height)
|
||||
/** The row of everybody else under the stage, and how tall it is. */
|
||||
const stripHeight = mode === 'pip' ? 42 : 74
|
||||
const stageFit = fitTiles(
|
||||
1,
|
||||
room.width,
|
||||
room.height - (tiles.length > 1 ? stripHeight + TILE_GAP : 0),
|
||||
// Uncapped: the whole point of picking somebody out is to see them larger
|
||||
// than the grid would ever draw them.
|
||||
Infinity
|
||||
)
|
||||
const phase =
|
||||
call.phase === 'ringing' ? 'Ringing…' : call.phase === 'connecting' ? 'Connecting…' : 'Connected'
|
||||
|
||||
// Who is in it, named. Only in the corner: in the conversation the tiles are
|
||||
// large enough to read the names off, and a second list of them would be the
|
||||
// same information twice.
|
||||
/**
|
||||
* A video element for one person in one place. Always muted: the call's
|
||||
* sound is played once, by CallAudio, not by every picture of every person
|
||||
* - two pictures of one person would otherwise be two voices.
|
||||
*/
|
||||
const video = (tile: CallTile, slot: string, className: string): JSX.Element => (
|
||||
<video
|
||||
ref={(el) => {
|
||||
const key = `${tile.id}#${slot}`
|
||||
if (el) {
|
||||
videos.current.set(key, el)
|
||||
if (tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream
|
||||
} else videos.current.delete(key)
|
||||
}}
|
||||
className={className}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
)
|
||||
|
||||
const buttons: StageButton[] = [
|
||||
{
|
||||
icon: call.muted ? 'mic_off' : 'mic',
|
||||
label: call.muted ? 'Unmute' : 'Mute',
|
||||
off: call.muted,
|
||||
onClick: () => store.toggleCallMute()
|
||||
},
|
||||
{
|
||||
icon: call.cameraOn ? 'videocam' : 'videocam_off',
|
||||
label: call.cameraOn ? 'Turn the camera off' : 'Turn the camera on',
|
||||
off: !call.cameraOn,
|
||||
onClick: () => void store.toggleCamera()
|
||||
},
|
||||
{
|
||||
icon: call.sharingScreen ? 'stop_screen_share' : 'screen_share',
|
||||
label: call.sharingScreen ? 'Stop sharing' : 'Share your screen',
|
||||
active: call.sharingScreen,
|
||||
onClick: () => void store.toggleScreenShare()
|
||||
},
|
||||
{ icon: 'call_end', label: 'Hang up', danger: true, onClick: () => store.hangUpMatrixCall() }
|
||||
]
|
||||
|
||||
if (mode !== 'pip') {
|
||||
const stageTiles: StageTile[] = tiles.map((tile) => ({
|
||||
id: tile.id,
|
||||
name: tile.label,
|
||||
speaking: tile.speaking,
|
||||
muted: tile.muted,
|
||||
picture: tile.hasVideo ? (slot) => video(tile, slot, 'stage-picture') : undefined
|
||||
}))
|
||||
return (
|
||||
<Stage
|
||||
title={bufferDisplayName(where) || 'Call'}
|
||||
subtitle={phase}
|
||||
tiles={stageTiles}
|
||||
buttons={buttons}
|
||||
where={mode}
|
||||
onPopOut={() => store.setCallPoppedOut(true)}
|
||||
onBringBack={() => store.setCallPoppedOut(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The corner player, for a call you have walked away from.
|
||||
const stage = tiles.find((t) => t.id === focused) ?? null
|
||||
const layout = fitTiles(Math.max(tiles.length, 1), room.width, room.height)
|
||||
const stripHeight = 42
|
||||
const stageFit = fitTiles(1, room.width, room.height - (tiles.length > 1 ? stripHeight + TILE_GAP : 0), Infinity)
|
||||
const everybody = tiles.map((t) => t.label).join(', ')
|
||||
|
||||
/**
|
||||
* One person's tile, wherever it is being drawn.
|
||||
*
|
||||
* A button rather than a div with a click on it: it is a control now, so it
|
||||
* is reached by keyboard and says what it does to anything reading the
|
||||
* window out loud.
|
||||
*/
|
||||
const drawTile = (
|
||||
tile: CallTile,
|
||||
slot: 'stage' | 'strip',
|
||||
@@ -170,21 +189,10 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
|
||||
title={stage?.id === tile.id ? 'Back to everybody' : `Look at ${tile.label}`}
|
||||
onClick={opts.onClick}
|
||||
>
|
||||
<video
|
||||
ref={(el) => {
|
||||
if (el) videos.current.set(`${tile.id}#${slot}`, el)
|
||||
else videos.current.delete(`${tile.id}#${slot}`)
|
||||
}}
|
||||
className={classes('video-tile-picture', !tile.hasVideo && 'audio-only')}
|
||||
autoPlay
|
||||
playsInline
|
||||
// Our own microphone coming back would be the oldest mistake in video
|
||||
// calling.
|
||||
muted={tile.self}
|
||||
/>
|
||||
{video(tile, `pip-${slot}`, classes('video-tile-picture', !tile.hasVideo && 'audio-only'))}
|
||||
{!tile.hasVideo && (
|
||||
<div className="video-tile-face">
|
||||
<Avatar name={tile.label} size={slot === 'strip' ? 24 : mode === 'pip' ? 32 : 44} />
|
||||
<Avatar name={tile.label} size={slot === 'strip' ? 24 : 32} />
|
||||
</div>
|
||||
)}
|
||||
{slot !== 'strip' && (
|
||||
@@ -198,7 +206,7 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
|
||||
|
||||
return (
|
||||
<VideoStage
|
||||
mode={mode}
|
||||
mode="pip"
|
||||
title={everybody}
|
||||
subtitle={`${phase}${where ? ` · ${bufferDisplayName(where)}` : ''}`}
|
||||
minimized={minimized}
|
||||
@@ -208,57 +216,28 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
|
||||
endLabel="Hang up"
|
||||
controls={
|
||||
<>
|
||||
<IconButton
|
||||
name={call.muted ? 'mic_off' : 'mic'}
|
||||
title={call.muted ? 'Unmute' : 'Mute'}
|
||||
className={call.muted ? 'calling' : undefined}
|
||||
onClick={() => store.toggleCallMute()}
|
||||
/>
|
||||
{/* Between the microphone and the screen, which is the order every
|
||||
client puts them in - and the order of how often they are
|
||||
pressed. On every call now, whatever shape it is: a media
|
||||
server, a mesh, or the one connection between two people. */}
|
||||
<IconButton
|
||||
name={call.cameraOn ? 'videocam' : 'videocam_off'}
|
||||
title={call.cameraOn ? 'Turn the camera off' : 'Turn the camera on'}
|
||||
className={call.cameraOn ? 'active' : undefined}
|
||||
onClick={() => void store.toggleCamera()}
|
||||
/>
|
||||
<IconButton
|
||||
name={call.sharingScreen ? 'stop_screen_share' : 'screen_share'}
|
||||
title={call.sharingScreen ? 'Stop sharing' : 'Share a screen or window'}
|
||||
className={call.sharingScreen ? 'active' : undefined}
|
||||
onClick={() => void store.toggleScreenShare()}
|
||||
/>
|
||||
{buttons.slice(0, 3).map((b) => (
|
||||
<IconButton
|
||||
key={b.label}
|
||||
name={b.icon}
|
||||
title={b.label}
|
||||
className={b.active ? 'active' : b.off ? 'calling' : undefined}
|
||||
onClick={b.onClick}
|
||||
/>
|
||||
))}
|
||||
<IconButton name="open_in_new" title="Pop out into its own window" onClick={() => store.setCallPoppedOut(true)} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Everybody in the call, one tile each, in as square a grid as the
|
||||
number allows - which is what every client with more than two people
|
||||
in a call has arrived at, because faces are roughly square and a row
|
||||
of six is six unreadable slivers.
|
||||
|
||||
Not a big-one-and-a-strip layout by default: that decides for
|
||||
somebody which person matters, and in a conversation between five
|
||||
people it is wrong most of the time. Pressing a face is how that
|
||||
decision gets made, and pressing it again is how it is unmade. */}
|
||||
{stage ? (
|
||||
<div className="video-stage" ref={grid}>
|
||||
{drawTile(stage, 'stage', {
|
||||
width: Math.floor(stageFit.tileWidth),
|
||||
onClick: () => setFocused(null)
|
||||
})}
|
||||
{drawTile(stage, 'stage', { width: Math.floor(stageFit.tileWidth), onClick: () => setFocused(null) })}
|
||||
{tiles.length > 1 && (
|
||||
/* Everybody else, still there and still watchable: a call does not
|
||||
become a broadcast because one person is being looked at. */
|
||||
<div className="video-strip" style={{ height: `${stripHeight}px` }}>
|
||||
{tiles
|
||||
.filter((t) => t.id !== stage.id)
|
||||
.map((tile) =>
|
||||
drawTile(tile, 'strip', {
|
||||
width: Math.round((stripHeight * 16) / 9),
|
||||
onClick: () => setFocused(tile.id)
|
||||
})
|
||||
drawTile(tile, 'strip', { width: Math.round((stripHeight * 16) / 9), onClick: () => setFocused(tile.id) })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -267,18 +246,62 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
|
||||
<div
|
||||
className="video-grid"
|
||||
ref={grid}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${layout.columns}, ${Math.floor(layout.tileWidth)}px)`
|
||||
}}
|
||||
style={{ gridTemplateColumns: `repeat(${layout.columns}, ${Math.floor(layout.tileWidth)}px)` }}
|
||||
>
|
||||
{tiles.map((tile) => drawTile(tile, 'stage', { onClick: () => setFocused(tile.id) }))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</VideoStage>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The call's sound: one hidden element per other person, in the main window.
|
||||
*
|
||||
* Kept apart from the pictures so that where the call is drawn - over the
|
||||
* conversation, in the corner, in a window of its own - never decides whether
|
||||
* it can be heard, and so that a person drawn twice is heard once.
|
||||
*/
|
||||
export function CallAudio(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const call = useChat((s) => s.activeCall)
|
||||
const [people, setPeople] = useState<CallTile[]>([])
|
||||
const sinks = useRef(new Map<string, HTMLAudioElement>())
|
||||
|
||||
useEffect(() => {
|
||||
if (!call) return
|
||||
const attach = (): void => {
|
||||
const others = store.callTiles().filter((t) => !t.self)
|
||||
setPeople((was) => (was.length === others.length && was.every((t, i) => t.id === others[i].id) ? was : others))
|
||||
for (const tile of others) {
|
||||
const el = sinks.current.get(tile.id)
|
||||
if (el && tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream
|
||||
}
|
||||
}
|
||||
attach()
|
||||
const timer = setInterval(attach, 500)
|
||||
return () => clearInterval(timer)
|
||||
}, [call, store])
|
||||
|
||||
if (!call) return null
|
||||
return (
|
||||
<>
|
||||
{people.map((tile) => (
|
||||
<audio
|
||||
key={tile.id}
|
||||
autoPlay
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
sinks.current.set(tile.id, el)
|
||||
if (tile.stream && el.srcObject !== tile.stream) el.srcObject = tile.stream
|
||||
} else sinks.current.delete(tile.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A call already happening in this room, and the way into it.
|
||||
*
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { DiscordStreamTile } from './DiscordStreamTile'
|
||||
import type { VoiceMember } from '../../../shared/wire'
|
||||
import { Stage, type StageButton, type StageTile } from './stage/Stage'
|
||||
import { FeedPicture } from './stage/FeedPicture'
|
||||
import { cameraKey, streamKey } from '../lib/framefeed'
|
||||
import type { VoiceMember, VoiceSession } from '../../../shared/wire'
|
||||
|
||||
/** How often to ask who is talking. */
|
||||
const POLL_MS = 300
|
||||
|
||||
/**
|
||||
* Where a microphone stops being a quiet room and starts being speech.
|
||||
*
|
||||
* The same threshold the level meter in the voice panel uses, measured on
|
||||
* real hardware: a silent room still reads around 0.008, so anything lower
|
||||
* would leave your own tile permanently ringed.
|
||||
*/
|
||||
const SPEAKING_FLOOR = 0.015
|
||||
|
||||
interface Levels {
|
||||
accountId: string
|
||||
micPeak: number
|
||||
@@ -15,53 +25,68 @@ interface Levels {
|
||||
}
|
||||
|
||||
/**
|
||||
* The call, while it is happening: who is in it, and who is talking.
|
||||
* The Discord call this conversation should be showing.
|
||||
*
|
||||
* Above the log rather than instead of it. A call and the conversation it is
|
||||
* in are the same conversation - people type links and reactions into a
|
||||
* channel while talking in it - and replacing the messages with faces would
|
||||
* make the two mutually exclusive.
|
||||
*
|
||||
* Only for the conversation being read. The panel above the account plaque is
|
||||
* the one that follows a call everywhere; this is the detailed view, and a
|
||||
* detailed view of a call you have navigated away from is just a picture of
|
||||
* somewhere else.
|
||||
* Two ways to match, because the two kinds of call are anchored differently.
|
||||
* A one-to-one call belongs to the DM it is in. A guild's voice channel
|
||||
* belongs to no text conversation - it is its own channel, with no buffer -
|
||||
* so it shows on whatever channel of that guild is open, which is where
|
||||
* Discord keeps it too.
|
||||
*/
|
||||
export function CallView({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
function useSessionFor(bufferId: string): VoiceSession | undefined {
|
||||
const sessions = useChat((s) => s.voiceSessions)
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const [members, setMembers] = useState<VoiceMember[]>([])
|
||||
const [speaking, setSpeaking] = useState<Record<string, number>>({})
|
||||
const [folded, setFolded] = useState(false)
|
||||
const sharing = useChat((st) => st.discordSharing)
|
||||
const watching = useChat((st) => st.discordWatching)
|
||||
|
||||
/**
|
||||
* The call this conversation should be showing.
|
||||
*
|
||||
* Two ways to match, because the two kinds of call are anchored
|
||||
* differently. A one-to-one call belongs to the DM it is in, and shows on
|
||||
* that conversation only. A guild's voice channel belongs to no text
|
||||
* conversation at all - it is its own channel, with no buffer - so it shows
|
||||
* on whatever channel of that guild is open, which is where Discord keeps
|
||||
* it too and the only place it could go without inventing a buffer for it.
|
||||
*/
|
||||
const buffer = buffers.find((b) => b.id === bufferId)
|
||||
const session = sessions.find(
|
||||
return sessions.find(
|
||||
(s) =>
|
||||
s.bufferId === bufferId ||
|
||||
(!!s.guildId && !!buffer?.groupId && buffer.groupId === `${s.accountId}|guild:${s.guildId}`)
|
||||
)
|
||||
const accountId = session?.accountId
|
||||
const channelId = session?.channelId
|
||||
}
|
||||
|
||||
/**
|
||||
* The Discord call, above the conversation it belongs to - or, once popped
|
||||
* out, a line saying where it went.
|
||||
*/
|
||||
export function CallView({ bufferId }: { bufferId: string }): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const session = useSessionFor(bufferId)
|
||||
const poppedOut = useChat((s) => s.callPoppedOut)
|
||||
const matrixCall = useChat((s) => s.activeCall)
|
||||
if (!session) return null
|
||||
// Only one call can be in the window of its own, and a Matrix call takes it
|
||||
// if there is one - see CallWindowHost.
|
||||
if (poppedOut && !matrixCall) {
|
||||
return (
|
||||
<div className="stage-away">
|
||||
<Icon name="open_in_new" size={16} />
|
||||
<span className="small">The call is in its own window.</span>
|
||||
<button type="button" className="button subtle" onClick={() => store.setCallPoppedOut(false)}>
|
||||
Bring it back
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <DiscordStage session={session} where="inline" />
|
||||
}
|
||||
|
||||
/** A Discord call drawn on the stage, wherever the stage is. */
|
||||
export function DiscordStage({ session, where }: { session: VoiceSession; where: 'inline' | 'window' }): JSX.Element {
|
||||
const store = useStore()
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const cameras = useChat((s) => s.discordCameras)
|
||||
const sharing = useChat((s) => s.discordSharing)
|
||||
const watching = useChat((s) => s.discordWatching)
|
||||
const voicePrefs = useChat((s) => s.voicePrefs)
|
||||
const [members, setMembers] = useState<VoiceMember[]>([])
|
||||
const [speaking, setSpeaking] = useState<Record<string, number>>({})
|
||||
const { accountId, channelId } = session
|
||||
|
||||
// Who is in it. Re-asked rather than pushed, because arriving and leaving a
|
||||
// call are voice-state dispatches this client does not otherwise subscribe
|
||||
// to, and a roster that only filled in once would show whoever happened to
|
||||
// be there when the call was joined.
|
||||
useEffect(() => {
|
||||
if (!accountId || !channelId) return
|
||||
let live = true
|
||||
const load = (): void => {
|
||||
void window.moho
|
||||
@@ -77,11 +102,9 @@ export function CallView({ bufferId }: { bufferId: string }): JSX.Element | null
|
||||
}
|
||||
}, [accountId, channelId])
|
||||
|
||||
// Who is talking. Faster than the roster because this is the part that has
|
||||
// to look live - a ring that lagged a second behind the voice would read as
|
||||
// the wrong person speaking rather than as a slow indicator.
|
||||
// Who is talking. Faster than the roster because a ring that lagged a second
|
||||
// behind the voice would read as the wrong person speaking.
|
||||
useEffect(() => {
|
||||
if (!accountId) return
|
||||
let live = true
|
||||
const tick = (): void => {
|
||||
void window.moho
|
||||
@@ -91,9 +114,8 @@ export function CallView({ bufferId }: { bufferId: string }): JSX.Element | null
|
||||
const mine = rows.find((r) => r.accountId === accountId)
|
||||
const next: Record<string, number> = {}
|
||||
for (const s of mine?.speakers ?? []) next[s.userId] = s.peak
|
||||
// Your own microphone never comes back through the call - you do
|
||||
// not receive your own audio - so the one person the speaker list
|
||||
// structurally cannot name is you. Filled in from the mic meter.
|
||||
// Your own microphone never comes back through the call, so the one
|
||||
// person the speaker list cannot name is you: filled in from the mic.
|
||||
if (mine && mine.micPeak > SPEAKING_FLOOR) next.self = mine.micPeak
|
||||
setSpeaking(next)
|
||||
})
|
||||
@@ -107,135 +129,75 @@ export function CallView({ bufferId }: { bufferId: string }): JSX.Element | null
|
||||
}
|
||||
}, [accountId])
|
||||
|
||||
if (!session) return null
|
||||
const tiles: StageTile[] = []
|
||||
for (const m of members) {
|
||||
const camera = cameras[`${accountId}|${m.userId}`]
|
||||
tiles.push({
|
||||
id: m.userId,
|
||||
name: m.isSelf ? `${m.nick} (you)` : m.nick,
|
||||
avatarUrl: m.avatarUrl,
|
||||
speaking: m.isSelf ? speaking.self !== undefined : (speaking[m.userId] ?? 0) > 0,
|
||||
muted: m.muted,
|
||||
deafened: m.deafened,
|
||||
picture: camera ? () => <FeedPicture feedKey={cameraKey(accountId, m.userId)} /> : undefined
|
||||
})
|
||||
// A shared screen is a tile of its own, the way Discord shows it: dark
|
||||
// until somebody chooses to watch, because watching opens a connection.
|
||||
if (m.streaming) {
|
||||
const watched = watching?.accountId === accountId && watching.userId === m.userId
|
||||
tiles.push({
|
||||
id: `stream:${m.userId}`,
|
||||
name: m.isSelf ? 'Your screen' : `${m.nick}’s screen`,
|
||||
avatarUrl: m.avatarUrl,
|
||||
live: true,
|
||||
picture: watched ? () => <FeedPicture feedKey={streamKey(accountId, watching.streamKey)} /> : undefined,
|
||||
// This account can be signed in here and in Discord's own client at
|
||||
// once, and then the stream is this account's and there is nothing
|
||||
// here to open.
|
||||
action: m.isSelf
|
||||
? undefined
|
||||
: {
|
||||
label: 'Watch stream',
|
||||
onClick: () => void store.watchDiscordStream(accountId, m.userId, m.nick, { channelId, guildId: session.guildId })
|
||||
},
|
||||
dismiss: watched ? { label: 'Stop watching', onClick: () => void store.stopWatchingDiscordStream() } : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const talking = (m: VoiceMember): boolean =>
|
||||
m.isSelf ? speaking.self !== undefined : (speaking[m.userId] ?? 0) > 0
|
||||
const buttons: StageButton[] = [
|
||||
{
|
||||
icon: voicePrefs.micMuted ? 'mic_off' : 'mic',
|
||||
label: voicePrefs.micMuted ? 'Unmute' : 'Mute',
|
||||
off: voicePrefs.micMuted,
|
||||
onClick: () => void store.setVoiceMuted({ micMuted: !voicePrefs.micMuted })
|
||||
},
|
||||
{
|
||||
icon: voicePrefs.deafened ? 'headset_off' : 'headset_mic',
|
||||
label: voicePrefs.deafened ? 'Undeafen' : 'Deafen',
|
||||
off: voicePrefs.deafened,
|
||||
onClick: () => void store.setVoiceMuted({ deafened: !voicePrefs.deafened })
|
||||
},
|
||||
{
|
||||
icon: sharing ? 'stop_screen_share' : 'screen_share',
|
||||
label: sharing ? 'Stop sharing' : 'Share your screen',
|
||||
active: sharing,
|
||||
onClick: () => void store.toggleScreenShare()
|
||||
},
|
||||
{ icon: 'call_end', label: 'Disconnect', danger: true, onClick: () => void store.leaveVoice(accountId) }
|
||||
]
|
||||
|
||||
const buffer = buffers.find((b) => b.id === session.bufferId)
|
||||
const title = session.isDirect ? `Call with ${buffer?.name ?? 'this conversation'}` : session.channelName
|
||||
return (
|
||||
<div className="call-view">
|
||||
<div className="call-view-head">
|
||||
<Icon name="call" size={16} />
|
||||
<span className="ellipsis">
|
||||
{session.isDirect ? `Call with ${buffer?.name ?? 'this conversation'}` : session.channelName}
|
||||
</span>
|
||||
<span className="small muted">
|
||||
{members.length > 0 ? `${members.length} in the call` : 'Connecting…'}
|
||||
</span>
|
||||
<IconButton
|
||||
name={folded ? 'expand_more' : 'expand_less'}
|
||||
title={folded ? 'Show who is in the call' : 'Hide the faces'}
|
||||
onClick={() => setFolded(!folded)}
|
||||
/>
|
||||
{/* The same button the Matrix call carries, in the same place and
|
||||
doing the same thing. A call is a call whichever service it is
|
||||
on, and the arrangement is settled by the one that has the most
|
||||
of it - see the controls row in CallStage. */}
|
||||
<IconButton
|
||||
name={sharing ? 'stop_screen_share' : 'screen_share'}
|
||||
title={sharing ? 'Stop sharing' : 'Share a screen or window'}
|
||||
className={sharing ? 'active' : undefined}
|
||||
onClick={() => void store.toggleScreenShare()}
|
||||
/>
|
||||
<IconButton
|
||||
name="call_end"
|
||||
title="Hang up"
|
||||
className="calling"
|
||||
onClick={() => void store.leaveVoice(session.accountId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!folded && <DiscordStreamTile />}
|
||||
|
||||
{!folded && (
|
||||
<div className="call-tiles">
|
||||
{/* Nobody at all means the roster has not arrived yet, not that the
|
||||
call is empty - you are in it, which is why it is on screen. */}
|
||||
{members.length === 0 && <div className="small muted">Waiting for the roster…</div>}
|
||||
{members.map((m) => (
|
||||
<div key={m.userId} className={talking(m) ? 'call-tile talking' : 'call-tile'}>
|
||||
<span className="call-face">
|
||||
<Avatar name={m.nick} url={m.avatarUrl} size={56} />
|
||||
{/* What they are doing, on the picture rather than beside the
|
||||
name: at this size the name is already the widest thing in
|
||||
the tile, and a row of words under it would set the tiles
|
||||
to different widths. */}
|
||||
<span className="call-badges">
|
||||
{m.streaming && (
|
||||
<span title={`${m.nick} is sharing a screen`}>
|
||||
<Icon name="screen_share" size={13} />
|
||||
</span>
|
||||
)}
|
||||
{m.video && (
|
||||
<span title={`${m.nick} has a camera on`}>
|
||||
<Icon name="videocam" size={13} />
|
||||
</span>
|
||||
)}
|
||||
{/* Deafened wins over muted: somebody who cannot hear is
|
||||
also not talking, and showing both says the same thing
|
||||
twice in a space with room for neither. */}
|
||||
{m.deafened ? (
|
||||
<span title={`${m.nick} cannot hear`}>
|
||||
<Icon name="headset_off" size={13} />
|
||||
</span>
|
||||
) : (
|
||||
m.muted && (
|
||||
<span title={`${m.nick} is muted`}>
|
||||
<Icon name="mic_off" size={13} />
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="small ellipsis">
|
||||
{m.nick}
|
||||
{m.isSelf && <span className="muted"> (you)</span>}
|
||||
</span>
|
||||
{m.streaming && (
|
||||
<button
|
||||
type="button"
|
||||
className={watching?.userId === m.userId ? 'call-watch small active' : 'call-watch small'}
|
||||
// "You" means this account, which is not the same as this
|
||||
// person: the same Discord login can be signed in here and
|
||||
// in the official client at once, and then the stream is
|
||||
// this account's and there is nothing here to open. Said
|
||||
// plainly, because "You are sharing a screen" beside
|
||||
// somebody else's live stream reads as moho having lost
|
||||
// track of who is who.
|
||||
title={
|
||||
m.isSelf
|
||||
? 'This account is the one sharing - open the conversation under the account you want to watch from'
|
||||
: watching?.userId === m.userId
|
||||
? `Stop watching ${m.nick}`
|
||||
: `Watch ${m.nick}’s screen`
|
||||
}
|
||||
disabled={m.isSelf}
|
||||
onClick={() => {
|
||||
if (watching?.userId === m.userId) void store.stopWatchingDiscordStream()
|
||||
else if (accountId && channelId) {
|
||||
void store.watchDiscordStream(accountId, m.userId, m.nick, {
|
||||
channelId,
|
||||
guildId: session?.guildId
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Stage
|
||||
title={title}
|
||||
subtitle={members.length > 0 ? `${members.length} in the call` : 'Connecting…'}
|
||||
tiles={tiles}
|
||||
buttons={buttons}
|
||||
where={where}
|
||||
onPopOut={() => store.setCallPoppedOut(true)}
|
||||
onBringBack={() => store.setCallPoppedOut(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a microphone stops being a quiet room and starts being speech.
|
||||
*
|
||||
* The same threshold the level meter in the voice panel uses, measured on
|
||||
* real hardware: a silent room still reads around 0.008, so anything lower
|
||||
* would leave your own tile permanently ringed.
|
||||
*/
|
||||
const SPEAKING_FLOOR = 0.015
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { IconButton } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { watchStream } from '../lib/discordwatch'
|
||||
|
||||
/**
|
||||
* Somebody else's shared screen, drawn in the call.
|
||||
*
|
||||
* A canvas rather than a `<video>`: the frames arrive as encoded VP8 from the
|
||||
* daemon rather than as a `MediaStream`, so there is nothing to put on
|
||||
* `srcObject` and the decoding is this window's job. `discordwatch.ts` does
|
||||
* that; this owns the canvas it draws into and the lifetime of the decoder.
|
||||
*
|
||||
* Shown only while something is being watched - one at a time, because there
|
||||
* is one place to draw a picture.
|
||||
*/
|
||||
export function DiscordStreamTile(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const watching = useChat((s) => s.discordWatching)
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!watching || !canvas.current) return
|
||||
const watch = watchStream(watching.accountId, watching.streamKey, canvas.current, (message) =>
|
||||
store.toast('error', message)
|
||||
)
|
||||
// Stopped on the way out, and on any change of stream: a decoder left
|
||||
// running would keep drawing the old picture into the new tile.
|
||||
return () => watch.stop()
|
||||
}, [watching?.accountId, watching?.streamKey])
|
||||
|
||||
if (!watching) return null
|
||||
|
||||
return (
|
||||
<div className="discord-stream-tile">
|
||||
<div className="discord-stream-bar">
|
||||
<span className="small ellipsis">{watching.nick}’s screen</span>
|
||||
<IconButton
|
||||
name="close"
|
||||
size={16}
|
||||
title="Stop watching"
|
||||
onClick={() => void store.stopWatchingDiscordStream()}
|
||||
/>
|
||||
</div>
|
||||
{/* Nothing is drawn until the first keyframe arrives, which is up to two
|
||||
seconds after the connection opens - so the canvas starts behind a
|
||||
line saying so rather than as an unexplained black rectangle. */}
|
||||
<canvas ref={canvas} className="discord-stream-canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { formatFullTime, formatTime, nickColor } from '../lib/util'
|
||||
|
||||
interface Summary {
|
||||
roomId: string
|
||||
name: string
|
||||
topic: string
|
||||
alias: string
|
||||
members: number
|
||||
joinRule: string
|
||||
membership: string
|
||||
encrypted: boolean
|
||||
worldReadable: boolean
|
||||
}
|
||||
|
||||
interface PeekLine {
|
||||
id: string
|
||||
from: string
|
||||
body: string
|
||||
isAction: boolean
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A look inside a room nobody here has joined, in the place a room is read.
|
||||
*
|
||||
* This used to open inside the search dialog, under the row it was about -
|
||||
* which put a conversation in a window built for choosing one, and left the
|
||||
* log somewhere it could not be read the way a log is read. It stands where
|
||||
* the conversation would instead: the same pane, the same row layout, and
|
||||
* nothing to type into, because there is nobody here to say it as.
|
||||
*
|
||||
* Joining is a membership event everybody in the room can see, and leaving
|
||||
* again leaves both of them behind - so looking and joining are separate acts,
|
||||
* and the bar under this says which of the two this is.
|
||||
*
|
||||
* Two halves, because homeservers permit them separately. The summary is
|
||||
* answered for any room the server can reach; the conversation needs the room
|
||||
* to be world-readable *and* this homeserver to hold a copy of it. Where it
|
||||
* does not, that is said in as many words, because silence there reads as an
|
||||
* empty room rather than as a door that is shut.
|
||||
*/
|
||||
export function PeekView(): JSX.Element | null {
|
||||
const peek = useChat((s) => s.peek)
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [lines, setLines] = useState<PeekLine[] | null>(null)
|
||||
const [failed, setFailed] = useState('')
|
||||
const [refused, setRefused] = useState('')
|
||||
const scroller = useRef<HTMLDivElement>(null)
|
||||
|
||||
const accountId = peek?.accountId
|
||||
const roomId = peek?.roomId
|
||||
const via = peek?.via
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !roomId) return
|
||||
// Whatever was on screen belongs to the room before this one.
|
||||
setSummary(null)
|
||||
setLines(null)
|
||||
setFailed('')
|
||||
setRefused('')
|
||||
let current = true
|
||||
void window.moho
|
||||
.rpc<Summary>('matrixRoomSummary', { accountId, room: roomId, via: via ? [via] : [] })
|
||||
.then((answer) => {
|
||||
if (!current) return
|
||||
setSummary(answer)
|
||||
// Only worth asking where the room itself would allow it - a room
|
||||
// that is not world-readable refuses however willing the homeserver
|
||||
// is, and an error for that would blame the wrong thing.
|
||||
if (!answer.worldReadable) return
|
||||
void window.moho
|
||||
.rpc<{ messages: PeekLine[] }>('matrixPeekRoom', {
|
||||
accountId,
|
||||
roomId: answer.roomId,
|
||||
limit: 50
|
||||
})
|
||||
.then((r) => current && setLines(r.messages ?? []))
|
||||
.catch((e: Error) => current && setRefused(e.message))
|
||||
})
|
||||
.catch((e: Error) => current && setFailed(e.message))
|
||||
return () => {
|
||||
current = false
|
||||
}
|
||||
}, [accountId, roomId, via])
|
||||
|
||||
// A log is read from its end.
|
||||
useEffect(() => {
|
||||
const el = scroller.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [lines])
|
||||
|
||||
if (!peek) return null
|
||||
|
||||
return (
|
||||
<div className="peek-view">
|
||||
{summary && (
|
||||
<div className="peek-facts small muted">
|
||||
{summary.members.toLocaleString()} members
|
||||
{summary.joinRule === 'knock'
|
||||
? ' · asks to be knocked on'
|
||||
: summary.joinRule === 'restricted'
|
||||
? ' · open to a space’s members'
|
||||
: summary.joinRule === 'invite'
|
||||
? ' · invitation only'
|
||||
: ''}
|
||||
{summary.encrypted ? ' · encrypted' : ''}
|
||||
{peek.via ? ` · ${peek.via}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary?.topic && <div className="peek-topic small">{summary.topic}</div>}
|
||||
|
||||
<div className="peek-log" ref={scroller}>
|
||||
{!summary && !failed && (
|
||||
<div className="messagelist-empty muted">
|
||||
<span className="spinner" />
|
||||
<span>Looking…</span>
|
||||
</div>
|
||||
)}
|
||||
{failed && <div className="messagelist-empty muted">Couldn’t look: {failed}</div>}
|
||||
|
||||
{summary && !summary.worldReadable && (
|
||||
<div className="messagelist-empty muted">
|
||||
This room’s history is only for people who have joined it, so there is nothing to read
|
||||
from out here.
|
||||
</div>
|
||||
)}
|
||||
{refused && <div className="messagelist-empty muted">{refused}</div>}
|
||||
{summary?.worldReadable && !lines && !refused && (
|
||||
<div className="messagelist-empty muted">
|
||||
<span className="spinner" />
|
||||
<span>Reading the latest messages…</span>
|
||||
</div>
|
||||
)}
|
||||
{lines?.length === 0 && (
|
||||
<div className="messagelist-empty muted">Nothing has been said here.</div>
|
||||
)}
|
||||
|
||||
{/* The same row the log draws, without what acts on a message: nothing
|
||||
here can be replied to, reacted to or quoted from a room you are
|
||||
not in. */}
|
||||
{lines?.map((line) => (
|
||||
<div key={line.id} className="message-row">
|
||||
<span className="message-time small muted" title={formatFullTime(line.ts)}>
|
||||
{formatTime(line.ts)}
|
||||
</span>
|
||||
<div className="message-content">
|
||||
<span className="message-byline">
|
||||
<span className="message-from" style={{ color: nickColor(line.from) }}>
|
||||
{line.isAction ? `* ${line.from}` : line.from}
|
||||
</span>
|
||||
</span>
|
||||
<span className="message-body selectable">{line.body}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the box would be, while a room is only being looked at.
|
||||
*
|
||||
* There is nobody here to say anything as, so the box is not offered - this
|
||||
* stands in its place and says what the pane is, with the one thing that
|
||||
* turns it into a conversation. Pressing Join takes the banner away and puts
|
||||
* the box back, inert until the room has actually arrived: the box belongs to
|
||||
* a conversation, and the room is not one for the moment or two the server
|
||||
* takes to let somebody in.
|
||||
*/
|
||||
export function PeekBar(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const peek = useChat((s) => s.peek)
|
||||
if (!peek) return null
|
||||
const title = peek.name || peek.alias || peek.roomId
|
||||
|
||||
if (peek.joining) {
|
||||
return (
|
||||
<div className="composer">
|
||||
<div className="divider-h" />
|
||||
<div className="composer-row">
|
||||
<div className="composer-input-wrap">
|
||||
<div className="text-field composer-input muted" aria-disabled="true">
|
||||
Joining {title}…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="peek-bar">
|
||||
<div className="divider-h" />
|
||||
<div className="peek-bar-row">
|
||||
<Icon name="visibility" size={16} />
|
||||
<div className="peek-bar-text">
|
||||
<span>
|
||||
You’re looking at <strong>{title}</strong> without having joined it.
|
||||
</span>
|
||||
<span className="small muted">Nobody in it can see you here.</span>
|
||||
</div>
|
||||
{peek.joinRule !== 'knock' && (
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => void store.joinPeeked().catch((e: Error) => store.toast('error', e.message))}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="button subtle" onClick={() => store.stopPeek()}>
|
||||
Stop looking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { formatTime } from '../lib/util'
|
||||
import type { Account } from '../../../shared/wire'
|
||||
|
||||
interface Summary {
|
||||
roomId: string
|
||||
name: string
|
||||
topic: string
|
||||
alias: string
|
||||
members: number
|
||||
joinRule: string
|
||||
membership: string
|
||||
encrypted: boolean
|
||||
worldReadable: boolean
|
||||
}
|
||||
|
||||
interface PeekLine {
|
||||
id: string
|
||||
from: string
|
||||
body: string
|
||||
isAction: boolean
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A look inside a room nobody here has joined.
|
||||
*
|
||||
* Joining is a membership event everybody in the room can see, and leaving
|
||||
* again leaves both of them behind - so without this, the decision to look
|
||||
* and the decision to join were the same act.
|
||||
*
|
||||
* Two halves, because homeservers permit them separately. The summary -
|
||||
* topic, size, how the room is entered, whether it is encrypted - is
|
||||
* answered for any room the server can reach, and is most of what somebody
|
||||
* decides on. The conversation needs the room to be world-readable *and* the
|
||||
* homeserver to allow peeking at all, which many do not; where it refuses,
|
||||
* that is said in as many words, because silence there reads as an empty
|
||||
* room rather than as a door that is shut.
|
||||
*/
|
||||
export function RoomPeek({
|
||||
account,
|
||||
room,
|
||||
via,
|
||||
onClose
|
||||
}: {
|
||||
account: Account
|
||||
room: string
|
||||
via?: string
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [lines, setLines] = useState<PeekLine[] | null>(null)
|
||||
const [failed, setFailed] = useState('')
|
||||
const [refused, setRefused] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void window.moho
|
||||
.rpc<Summary>('matrixRoomSummary', { accountId: account.id, room, via: via ? [via] : [] })
|
||||
.then((answer) => {
|
||||
setSummary(answer)
|
||||
// Only worth asking where the room itself would allow it - a room
|
||||
// that is not world-readable refuses however willing the homeserver
|
||||
// is, and an error for that would blame the wrong thing.
|
||||
if (!answer.worldReadable) return
|
||||
void window.moho
|
||||
.rpc<{ messages: PeekLine[] }>('matrixPeekRoom', {
|
||||
accountId: account.id,
|
||||
roomId: answer.roomId,
|
||||
limit: 20
|
||||
})
|
||||
.then((r) => setLines(r.messages))
|
||||
.catch((e: Error) => setRefused(e.message))
|
||||
})
|
||||
.catch((e: Error) => setFailed(e.message))
|
||||
}, [account.id, room])
|
||||
|
||||
return (
|
||||
<div className="room-peek">
|
||||
<div className="room-peek-head">
|
||||
<Icon name="visibility" size={16} />
|
||||
<span className="ellipsis">{summary?.name || summary?.alias || room}</span>
|
||||
<button type="button" className="button subtle" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!summary && !failed && <div className="small muted">Looking…</div>}
|
||||
{failed && <div className="small muted">Couldn’t look: {failed}</div>}
|
||||
|
||||
{summary && (
|
||||
<>
|
||||
<div className="small muted">
|
||||
{summary.members.toLocaleString()} members
|
||||
{summary.joinRule === 'knock'
|
||||
? ' · asks to be knocked on'
|
||||
: summary.joinRule === 'restricted'
|
||||
? ' · open to a space’s members'
|
||||
: summary.joinRule === 'invite'
|
||||
? ' · invitation only'
|
||||
: ''}
|
||||
{summary.encrypted ? ' · encrypted' : ''}
|
||||
</div>
|
||||
{summary.topic && <div className="small room-peek-topic">{summary.topic}</div>}
|
||||
|
||||
{/* Why there is no conversation below, in each of the two cases -
|
||||
the room's own rule, and the homeserver's. They are different
|
||||
answers and only one of them is about this room. */}
|
||||
{!summary.worldReadable && (
|
||||
<div className="small muted">
|
||||
This room’s history is only for people who have joined it, so there is nothing to
|
||||
read from out here.
|
||||
</div>
|
||||
)}
|
||||
{refused && <div className="small muted">{refused}</div>}
|
||||
|
||||
{lines?.length === 0 && <div className="small muted">Nothing has been said here.</div>}
|
||||
{lines && lines.length > 0 && (
|
||||
<div className="room-peek-log">
|
||||
{lines.map((line) => (
|
||||
<div key={line.id} className="room-peek-line">
|
||||
<span className="small muted tabular">{formatTime(line.ts)}</span>
|
||||
<span className="room-peek-from">{line.isAction ? `* ${line.from}` : line.from}</span>
|
||||
<span className="room-peek-body">{line.body}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Avatar } from './Avatar'
|
||||
import { RoomPeek } from './RoomPeek'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { usePref, useStore } from '../state/hooks'
|
||||
import { classes } from '../lib/util'
|
||||
@@ -23,6 +22,8 @@ interface PublicRoom {
|
||||
* One button for all three is a button that fails for two of them.
|
||||
*/
|
||||
joinRule?: string
|
||||
/** Whether the room itself allows being read without joining it. */
|
||||
worldReadable?: boolean
|
||||
}
|
||||
|
||||
/** One homeserver that was asked, and what came of asking it. */
|
||||
@@ -89,6 +90,19 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
|
||||
* across restarts, until it is taken off the line.
|
||||
*/
|
||||
const [extraServers, setExtraServers] = usePref<string[]>('roomSearchServers', [])
|
||||
/**
|
||||
* Directories the daemon suggests, for somebody who has not yet heard of
|
||||
* any of them. Asked of the daemon because the list is knowledge about
|
||||
* Matrix rather than about this window, and the next client will want it too.
|
||||
*/
|
||||
const [suggested, setSuggested] = useState<{ server: string; about: string }[]>([])
|
||||
const [showSuggested, setShowSuggested] = useState(false)
|
||||
useEffect(() => {
|
||||
void window.moho
|
||||
.rpc<{ server: string; about: string }[]>('suggestedMatrixDirectories', {})
|
||||
.then((list) => setSuggested(Array.isArray(list) ? list : []))
|
||||
.catch(() => setSuggested([]))
|
||||
}, [])
|
||||
/** What is in the box, which is not yet a server until Enter says so. */
|
||||
const [pendingServer, setPendingServer] = useState('')
|
||||
const [answer, setAnswer] = useState<Answer | null>(null)
|
||||
@@ -102,8 +116,6 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
|
||||
/** The room being knocked on, while the reason is being written. */
|
||||
const [knocking, setKnocking] = useState<PublicRoom | null>(null)
|
||||
const [joining, setJoining] = useState<Record<string, boolean>>({})
|
||||
/** Which room somebody has asked to look into without joining it. */
|
||||
const [peeking, setPeeking] = useState<PublicRoom | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const resultsRef = useRef<HTMLDivElement>(null)
|
||||
// Which search the answer in flight belongs to. A slow server answering a
|
||||
@@ -143,6 +155,9 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
|
||||
if (!extraServers.includes(name)) setExtraServers([...extraServers, name])
|
||||
}
|
||||
|
||||
/** Suggestions not yet on the line - the ones still worth offering. */
|
||||
const unadded = suggested.filter((d) => !extraServers.includes(d.server))
|
||||
|
||||
/**
|
||||
* Every server worth a chip: the ones that answered, and the ones added by
|
||||
* hand that have not yet.
|
||||
@@ -377,6 +392,47 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Where to look beyond your own server. Closed until asked for, and
|
||||
nothing in it is searched until it is added: each one is a request
|
||||
to somebody else's server. */}
|
||||
{unadded.length > 0 && (
|
||||
<div className="room-search-suggested small">
|
||||
<button
|
||||
type="button"
|
||||
className="room-search-suggested-toggle"
|
||||
aria-expanded={showSuggested}
|
||||
onClick={() => setShowSuggested((v) => !v)}
|
||||
>
|
||||
{showSuggested ? 'Hide' : 'Find rooms beyond your server'} · {unadded.length} suggested
|
||||
</button>
|
||||
{showSuggested && (
|
||||
<>
|
||||
<div className="room-search-suggested-list">
|
||||
{unadded.map((d) => (
|
||||
<button
|
||||
key={d.server}
|
||||
type="button"
|
||||
className="room-search-suggestion"
|
||||
title={d.about}
|
||||
onClick={() => setExtraServers([...extraServers, d.server])}
|
||||
>
|
||||
<span className="room-search-suggestion-name">{d.server}</span>
|
||||
<span className="muted">{d.about}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="room-search-suggested-all"
|
||||
onClick={() => setExtraServers([...extraServers, ...unadded.map((d) => d.server)])}
|
||||
>
|
||||
Add all {unadded.length}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="room-search-results" ref={resultsRef} onScroll={onScroll}>
|
||||
{rooms.map((room) => (
|
||||
<div key={room.roomId} className="room-search-row">
|
||||
@@ -404,12 +460,24 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
|
||||
and leaving again leaves both behind - so the two decisions
|
||||
have to be separable, and on a room already joined there is
|
||||
nothing to separate. */}
|
||||
{!room.joined && (
|
||||
{!room.joined && room.worldReadable && (
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
title="See what is in it without joining"
|
||||
onClick={() => setPeeking(peeking?.roomId === room.roomId ? null : room)}
|
||||
// In the conversation pane, where a room is read, and the
|
||||
// dialog goes: it was for choosing, and this is chosen.
|
||||
onClick={() => {
|
||||
store.startPeek({
|
||||
accountId: account.id,
|
||||
roomId: room.roomId,
|
||||
alias: room.alias,
|
||||
name: room.name,
|
||||
via: room.via,
|
||||
joinRule: room.joinRule
|
||||
})
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Icon name="visibility" size={15} /> Look
|
||||
</button>
|
||||
@@ -431,15 +499,6 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
|
||||
</div>
|
||||
))}
|
||||
|
||||
{peeking && (
|
||||
<RoomPeek
|
||||
account={account}
|
||||
room={peeking.roomId}
|
||||
via={peeking.via}
|
||||
onClose={() => setPeeking(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{knocking && (
|
||||
<ReasonPrompt
|
||||
title={`Ask to join ${knocking.name || knocking.alias || knocking.roomId}`}
|
||||
|
||||
@@ -815,6 +815,17 @@ function MatrixSettings(): JSX.Element {
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title="Calls"
|
||||
description="A call between two networks needs help finding its way through each one's router. Your homeserver's relay is meant to do that; when it is missing, or listed and not answering, there is nothing to fall back on."
|
||||
>
|
||||
<ToggleSetting
|
||||
settingKey="matrix.fallbackStun"
|
||||
label="Use matrix.org as a backup for calls"
|
||||
description="Asks matrix.org's public STUN server what your address looks like from outside, alongside your homeserver's own relay. That tells matrix.org your IP address and nothing else: no audio or video passes through it, and there is no account or call detail in the request. It cannot rescue a call behind a strict router, where only a working relay helps. Off means a call connects only when both ends can already reach each other, or your homeserver's relay works."
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<MembershipAnnouncements />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useChat, useStore } from '../../state/hooks'
|
||||
import { StageWindow } from './StageWindow'
|
||||
import { CallStage } from '../CallStage'
|
||||
import { DiscordStage } from '../CallView'
|
||||
|
||||
/**
|
||||
* Whichever call is happening, in its own window, while it is popped out.
|
||||
*
|
||||
* One window for one call: a Matrix call if there is one - its media lives in
|
||||
* this renderer and has nowhere else to go - and otherwise the Discord call.
|
||||
* When the call ends the window goes with it.
|
||||
*/
|
||||
export function CallWindowHost(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const poppedOut = useChat((s) => s.callPoppedOut)
|
||||
const matrix = useChat((s) => s.activeCall)
|
||||
const sessions = useChat((s) => s.voiceSessions)
|
||||
const discord = sessions[0]
|
||||
const anything = !!matrix || !!discord
|
||||
|
||||
useEffect(() => {
|
||||
if (poppedOut && !anything) store.setCallPoppedOut(false)
|
||||
}, [poppedOut, anything, store])
|
||||
|
||||
if (!poppedOut || !anything) return null
|
||||
return (
|
||||
<StageWindow onClosed={() => store.setCallPoppedOut(false)}>
|
||||
{matrix ? <CallStage mode="window" /> : discord ? <DiscordStage session={discord} where="window" /> : null}
|
||||
</StageWindow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { showFeed } from '../../lib/framefeed'
|
||||
|
||||
/**
|
||||
* A camera or a shared screen from Discord, drawn from its feed (see
|
||||
* framefeed.ts). Any number of these can show one feed at once, in either
|
||||
* window, without starting a decoder of their own.
|
||||
*/
|
||||
export function FeedPicture({ feedKey }: { feedKey: string }): JSX.Element {
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
useEffect(() => {
|
||||
if (!canvas.current) return
|
||||
return showFeed(feedKey, canvas.current)
|
||||
}, [feedKey])
|
||||
return <canvas ref={canvas} className="stage-picture" />
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react'
|
||||
import { Icon } from '../Icon'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { usePref } from '../../state/hooks'
|
||||
import { classes, nickColor } from '../../lib/util'
|
||||
|
||||
/**
|
||||
* A call, drawn the way Discord's desktop client draws one.
|
||||
*
|
||||
* A black stage above the conversation that looks like a video before it has
|
||||
* any video in it: everybody a rounded sixteen-by-nine tile - their camera
|
||||
* where they have one on, otherwise their picture on a panel tinted with
|
||||
* their colour - a green ring on whoever is talking, their name in a pill in
|
||||
* the corner, and the buttons in a row along the bottom. Pressing somebody
|
||||
* makes them large with everybody else in a strip underneath; pressing them
|
||||
* again puts the grid back.
|
||||
*
|
||||
* Service-blind. A Matrix call and a Discord call hand this the same tiles
|
||||
* and the same buttons; which service it is decides what the tiles contain,
|
||||
* not what the stage looks like.
|
||||
*/
|
||||
|
||||
export interface StageTile {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
speaking?: boolean
|
||||
muted?: boolean
|
||||
deafened?: boolean
|
||||
/** A shared screen rather than a person, badged as live. */
|
||||
live?: boolean
|
||||
/**
|
||||
* The picture, where there is one: a camera or a shared screen.
|
||||
*
|
||||
* A function rather than an element because the same person can be drawn
|
||||
* twice at once - large on the stage and small in the strip - and each
|
||||
* place needs an element of its own.
|
||||
*/
|
||||
picture?: (slot: 'main' | 'strip') => ReactNode
|
||||
/** A button across the middle of a tile with no picture: "Watch stream". */
|
||||
action?: { label: string; onClick: () => void }
|
||||
/** A small button in the corner of a tile with one: "Stop watching". */
|
||||
dismiss?: { label: string; onClick: () => void }
|
||||
}
|
||||
|
||||
export interface StageButton {
|
||||
icon: string
|
||||
label: string
|
||||
/** On, in the sense of a toggle that is doing something now. */
|
||||
active?: boolean
|
||||
/** Off in the way that matters - a muted microphone, a camera off. */
|
||||
off?: boolean
|
||||
danger?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** Sixteen by nine, which is what cameras and screens both are. */
|
||||
const TILE_RATIO = 16 / 9
|
||||
const TILE_GAP = 8
|
||||
/** No tile in the grid gets bigger than this, however much room there is. */
|
||||
const TILE_MAX = 560
|
||||
|
||||
/**
|
||||
* How to lay out this many tiles in this much room.
|
||||
*
|
||||
* Every column count is tried and the one that makes the tiles biggest wins:
|
||||
* three people in a wide short stage want one row, the same three in a tall
|
||||
* narrow one want three.
|
||||
*/
|
||||
export function fitTiles(
|
||||
count: number,
|
||||
width: number,
|
||||
height: number,
|
||||
max: number = TILE_MAX
|
||||
): { columns: number; tileWidth: number } {
|
||||
if (count < 1 || width < 1) return { columns: 1, tileWidth: 0 }
|
||||
let best = { columns: 1, tileWidth: 0 }
|
||||
for (let columns = 1; columns <= count; columns++) {
|
||||
const rows = Math.ceil(count / columns)
|
||||
const byWidth = (width - TILE_GAP * (columns - 1)) / columns
|
||||
const byHeight = ((height - TILE_GAP * (rows - 1)) / rows) * TILE_RATIO
|
||||
const tileWidth = Math.min(byWidth, byHeight, max)
|
||||
if (tileWidth > best.tileWidth) best = { columns, tileWidth }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** The room the buttons take along the bottom. */
|
||||
const CONTROLS_SPACE = 76
|
||||
/** And the title along the top. */
|
||||
const TITLE_SPACE = 44
|
||||
const STRIP_HEIGHT = 96
|
||||
const MIN_HEIGHT = 220
|
||||
|
||||
export function Stage({
|
||||
title,
|
||||
subtitle,
|
||||
tiles,
|
||||
buttons,
|
||||
where,
|
||||
onPopOut,
|
||||
onBringBack
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
tiles: StageTile[]
|
||||
buttons: StageButton[]
|
||||
/**
|
||||
* Above the conversation, or filling a window of its own. Inline, the
|
||||
* stage can be collapsed to a bar and resized by its bottom edge; in its
|
||||
* own window it is the window.
|
||||
*/
|
||||
where: 'inline' | 'window'
|
||||
onPopOut?: () => void
|
||||
onBringBack?: () => void
|
||||
}): JSX.Element {
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const area = useRef<HTMLDivElement>(null)
|
||||
const [focused, setFocused] = useState<string | null>(null)
|
||||
const [room, setRoom] = useState({ width: 640, height: 300 })
|
||||
const [collapsed, setCollapsed] = usePref<boolean>('ui.stageCollapsed', false)
|
||||
/**
|
||||
* How tall the stage stands above the conversation, as somebody left it.
|
||||
* Remembered, because it is a decision about this screen - a laptop wants a
|
||||
* short stage and a monitor a tall one - and not one to make every call.
|
||||
*/
|
||||
const [savedHeight, setSavedHeight] = usePref<number>('ui.stageHeight', 380)
|
||||
const [height, setHeight] = useState(savedHeight)
|
||||
useEffect(() => setHeight(savedHeight), [savedHeight])
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
|
||||
// Measured, in whichever window the stage is in: a stage popped out is
|
||||
// measured against its own window, not the one that opened it.
|
||||
useEffect(() => {
|
||||
const el = area.current
|
||||
if (!el) return
|
||||
const view = el.ownerDocument.defaultView ?? window
|
||||
const measure = (): void => setRoom({ width: el.clientWidth, height: el.clientHeight })
|
||||
measure()
|
||||
const observer = new view.ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [collapsed, where])
|
||||
|
||||
useEffect(() => {
|
||||
const doc = root.current?.ownerDocument
|
||||
if (!doc) return
|
||||
const follow = (): void => setFullscreen(!!doc.fullscreenElement)
|
||||
doc.addEventListener('fullscreenchange', follow)
|
||||
return () => doc.removeEventListener('fullscreenchange', follow)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = (): void => {
|
||||
const el = root.current
|
||||
if (!el) return
|
||||
if (el.ownerDocument.fullscreenElement) void el.ownerDocument.exitFullscreen().catch(() => {})
|
||||
else void el.requestFullscreen().catch(() => {})
|
||||
}
|
||||
|
||||
// Resizing by the bottom edge, inline only.
|
||||
const drag = useRef<{ startY: number; startHeight: number } | null>(null)
|
||||
const startResize = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
drag.current = { startY: e.clientY, startHeight: height }
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
const onResize = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
if (!drag.current) return
|
||||
const view = e.currentTarget.ownerDocument.defaultView ?? window
|
||||
const max = Math.max(MIN_HEIGHT, view.innerHeight * 0.8)
|
||||
setHeight(Math.round(Math.min(max, Math.max(MIN_HEIGHT, drag.current.startHeight + e.clientY - drag.current.startY))))
|
||||
}
|
||||
const endResize = (e: PointerEvent<HTMLDivElement>): void => {
|
||||
if (!drag.current) return
|
||||
drag.current = null
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
setSavedHeight(height)
|
||||
}
|
||||
|
||||
// Somebody who has left cannot go on being the one looked at.
|
||||
const stage = tiles.find((t) => t.id === focused) ?? null
|
||||
const others = stage ? tiles.filter((t) => t.id !== stage.id) : []
|
||||
const grid = fitTiles(Math.max(tiles.length, 1), room.width, room.height)
|
||||
const stageFit = fitTiles(1, room.width, room.height - (others.length > 0 ? STRIP_HEIGHT + TILE_GAP : 0), Infinity)
|
||||
|
||||
const drawTile = (tile: StageTile, slot: 'main' | 'strip', width: number): JSX.Element => {
|
||||
const picture = tile.picture?.(slot)
|
||||
const small = slot === 'strip' || width < 220
|
||||
return (
|
||||
<div
|
||||
key={`${tile.id}#${slot}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={classes('stage-tile', tile.speaking && 'speaking', slot === 'strip' && 'strip')}
|
||||
style={{
|
||||
width: `${Math.floor(width)}px`,
|
||||
// Tinted with the person's colour, the way Discord tints a tile with
|
||||
// somebody's banner: a room of identical grey boxes is a room nobody
|
||||
// can tell apart at a glance.
|
||||
background: picture ? '#000' : tile.live ? '#1e1f22' : `color-mix(in srgb, ${nickColor(tile.name)} 38%, #111214)`
|
||||
}}
|
||||
title={stage?.id === tile.id ? 'Back to everybody' : `Focus on ${tile.name}`}
|
||||
onClick={() => setFocused(stage?.id === tile.id ? null : tile.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setFocused(stage?.id === tile.id ? null : tile.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* A screen nobody is watching yet is a dark panel with a button on
|
||||
it, not a face: the face is on the person's own tile beside it. */}
|
||||
{picture ?? (tile.live && tile.action ? null : (
|
||||
<span className="stage-tile-face">
|
||||
<Avatar name={tile.name} url={tile.avatarUrl} size={small ? 40 : Math.min(96, Math.max(48, width / 5))} />
|
||||
</span>
|
||||
))}
|
||||
{tile.live && <span className="stage-tile-live">LIVE</span>}
|
||||
{!picture && tile.action && slot === 'main' && (
|
||||
<button
|
||||
type="button"
|
||||
className="stage-tile-action"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
tile.action?.onClick()
|
||||
}}
|
||||
>
|
||||
{tile.action.label}
|
||||
</button>
|
||||
)}
|
||||
{picture && tile.dismiss && (
|
||||
<button
|
||||
type="button"
|
||||
className="stage-tile-dismiss"
|
||||
title={tile.dismiss.label}
|
||||
aria-label={tile.dismiss.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
tile.dismiss?.onClick()
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
<span className="stage-tile-name">
|
||||
{tile.deafened ? (
|
||||
<Icon name="headset_off" size={14} className="stage-tile-state" />
|
||||
) : tile.muted ? (
|
||||
<Icon name="mic_off" size={14} className="stage-tile-state" />
|
||||
) : null}
|
||||
<span className="ellipsis">{tile.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const head = (
|
||||
<div className={classes('stage-head', where === 'window' && 'window-drag')}>
|
||||
<span className="stage-title ellipsis">
|
||||
<Icon name="volume_up" size={18} />
|
||||
<span className="ellipsis">{title}</span>
|
||||
{subtitle && <span className="stage-subtitle ellipsis">{subtitle}</span>}
|
||||
</span>
|
||||
<span className="stage-head-buttons">
|
||||
{where === 'inline' && onPopOut && (
|
||||
<HeadButton icon="open_in_new" label="Pop out into its own window" onClick={onPopOut} />
|
||||
)}
|
||||
{where === 'window' && onBringBack && (
|
||||
<HeadButton icon="close_fullscreen" label="Put it back in the conversation" onClick={onBringBack} />
|
||||
)}
|
||||
<HeadButton
|
||||
icon={fullscreen ? 'fullscreen_exit' : 'fullscreen'}
|
||||
label={fullscreen ? 'Leave full screen' : 'Full screen'}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
{where === 'inline' && (
|
||||
<HeadButton
|
||||
icon={collapsed ? 'expand_more' : 'expand_less'}
|
||||
label={collapsed ? 'Show the call' : 'Hide the call'}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (where === 'inline' && collapsed) {
|
||||
return (
|
||||
<div className="stage collapsed" ref={root}>
|
||||
{head}
|
||||
<div className="stage-controls compact">
|
||||
{buttons.map((b) => (
|
||||
<ControlButton key={b.label} button={b} compact />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes('stage', where === 'window' && 'in-window', fullscreen && 'fullscreen')}
|
||||
ref={root}
|
||||
style={where === 'inline' && !fullscreen ? { height: `${height}px` } : undefined}
|
||||
>
|
||||
{head}
|
||||
<div
|
||||
className="stage-area"
|
||||
ref={area}
|
||||
style={{ top: `${TITLE_SPACE}px`, bottom: `${CONTROLS_SPACE}px` }}
|
||||
>
|
||||
{tiles.length === 0 && <span className="stage-empty">Waiting for everybody…</span>}
|
||||
{stage ? (
|
||||
<div className="stage-focus">
|
||||
{drawTile(stage, 'main', stageFit.tileWidth)}
|
||||
{others.length > 0 && (
|
||||
<div className="stage-strip" style={{ height: `${STRIP_HEIGHT}px` }}>
|
||||
{others.map((tile) => drawTile(tile, 'strip', (STRIP_HEIGHT * 16) / 9))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="stage-grid"
|
||||
style={{ gridTemplateColumns: `repeat(${grid.columns}, ${Math.floor(grid.tileWidth)}px)` }}
|
||||
>
|
||||
{tiles.map((tile) => drawTile(tile, 'main', grid.tileWidth))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="stage-controls">
|
||||
{buttons.map((b) => (
|
||||
<ControlButton key={b.label} button={b} />
|
||||
))}
|
||||
</div>
|
||||
{where === 'inline' && !fullscreen && (
|
||||
<div
|
||||
className="stage-resize"
|
||||
title="Drag to resize"
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResize}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeadButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }): JSX.Element {
|
||||
return (
|
||||
<button type="button" className="stage-head-button" title={label} aria-label={label} onClick={onClick}>
|
||||
<Icon name={icon} size={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({ button, compact }: { button: StageButton; compact?: boolean }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes(
|
||||
'stage-button',
|
||||
compact && 'compact',
|
||||
button.active && 'active',
|
||||
button.off && 'off',
|
||||
button.danger && 'danger'
|
||||
)}
|
||||
title={button.label}
|
||||
aria-label={button.label}
|
||||
aria-pressed={button.active || button.off ? true : undefined}
|
||||
onClick={button.onClick}
|
||||
>
|
||||
<Icon name={button.icon} size={compact ? 18 : 24} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/** The name the main process recognises and lets open - see main/index.ts. */
|
||||
export const STAGE_WINDOW_NAME = 'moho-call'
|
||||
|
||||
/**
|
||||
* A call in a window of its own.
|
||||
*
|
||||
* Opened from this window and drawn into by it, rather than being a second
|
||||
* copy of the app: a Matrix call's media lives in this renderer - WebRTC
|
||||
* belongs to the page that opened it - and a separate window would have no
|
||||
* way to show a stream it does not own. A child window of this one shares the
|
||||
* same JavaScript, the same store and the same live streams, so the stage
|
||||
* that was above the conversation a moment ago is simply drawn over there
|
||||
* instead, mid-call, with nothing reconnected.
|
||||
*
|
||||
* The child starts blank, so it is given this window's stylesheets and theme
|
||||
* and told where relative URLs are - the icon font is one - before anything
|
||||
* is drawn into it.
|
||||
*/
|
||||
export function StageWindow({ children, onClosed }: { children: ReactNode; onClosed: () => void }): JSX.Element | null {
|
||||
const [mount, setMount] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const child = window.open('', STAGE_WINDOW_NAME, 'width=1024,height=640')
|
||||
if (!child) {
|
||||
onClosed()
|
||||
return
|
||||
}
|
||||
const doc = child.document
|
||||
doc.title = 'moho — call'
|
||||
const base = doc.createElement('base')
|
||||
base.href = document.baseURI
|
||||
doc.head.appendChild(base)
|
||||
for (const node of document.head.querySelectorAll('style, link[rel="stylesheet"], meta[http-equiv]')) {
|
||||
doc.head.appendChild(node.cloneNode(true))
|
||||
}
|
||||
// The theme lives on the root element as attributes and inline custom
|
||||
// properties; without them the stage's chrome would be the wrong colours.
|
||||
for (const attr of Array.from(document.documentElement.attributes)) {
|
||||
doc.documentElement.setAttribute(attr.name, attr.value)
|
||||
}
|
||||
doc.body.className = document.body.className
|
||||
doc.body.style.margin = '0'
|
||||
doc.body.style.background = '#000'
|
||||
const container = doc.createElement('div')
|
||||
container.className = 'stage-window-root'
|
||||
doc.body.appendChild(container)
|
||||
setMount(container)
|
||||
|
||||
// Closed from its own title bar or the compositor, rather than from the
|
||||
// button that puts it back.
|
||||
let done = false
|
||||
const closed = (): void => {
|
||||
if (done) return
|
||||
done = true
|
||||
onClosed()
|
||||
}
|
||||
child.addEventListener('pagehide', closed)
|
||||
const watch = window.setInterval(() => child.closed && closed(), 500)
|
||||
return () => {
|
||||
done = true
|
||||
window.clearInterval(watch)
|
||||
child.removeEventListener('pagehide', closed)
|
||||
if (!child.closed) child.close()
|
||||
}
|
||||
// Opened once per popping-out; the callback changing does not reopen it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return mount ? createPortal(children, mount) : null
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* What a call did, written down where somebody can read it afterwards.
|
||||
*
|
||||
* A call fails in one of a dozen places that all look the same from the
|
||||
* window - "no picture" - and the honest answer to which one is in the
|
||||
* signalling, the descriptions each end sent, the network path that was
|
||||
* chosen and whether any media crossed it. None of that is visible in a
|
||||
* running call, and most of it is gone once the call ends, so this keeps it.
|
||||
*
|
||||
* Lines are written to the console with a `[call]` prefix; the main process
|
||||
* copies exactly those into moho.log, which outlives the window. One JSON
|
||||
* object per line, so a person can read it and a script can too.
|
||||
*
|
||||
* What it will not write: ICE credentials, DTLS fingerprints, the ufrag and
|
||||
* password lines of a description, or addresses. A log somebody sends to
|
||||
* another person to explain a fault should not also be a key to the call.
|
||||
*/
|
||||
|
||||
const started = new Map<string, number>()
|
||||
|
||||
/** One thing that happened in a call. */
|
||||
export function callLog(callId: string, event: string, detail: Record<string, unknown> = {}): void {
|
||||
const now = Date.now()
|
||||
if (!started.has(callId)) started.set(callId, now)
|
||||
const line = { call: callId.slice(0, 10), t: now - (started.get(callId) ?? now), event, ...detail }
|
||||
try {
|
||||
console.info(`[call] ${JSON.stringify(line)}`)
|
||||
} catch {
|
||||
/* a log line must never be the thing that breaks a call */
|
||||
}
|
||||
}
|
||||
|
||||
/** A call is over: its clock and its counters are no longer needed. */
|
||||
export function callLogEnd(callId: string): void {
|
||||
started.delete(callId)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a session description says, without what it holds.
|
||||
*
|
||||
* The parts that decide whether a picture can flow: which sections exist,
|
||||
* which way each one points, whether it was refused (port 0), and which
|
||||
* codecs it offers. `dir=inactive` or `port=0` on a video section is the
|
||||
* whole answer to "why was there no video" more often than anything deeper.
|
||||
*/
|
||||
export function describeSdp(sdp: string | undefined | null): Record<string, unknown> {
|
||||
if (!sdp) return { sdp: 'none' }
|
||||
const lines = sdp.split(/\r?\n/)
|
||||
const sections: Record<string, unknown>[] = []
|
||||
let current: Record<string, unknown> | null = null
|
||||
const rtpmap = new Map<string, string>()
|
||||
const candidates: Record<string, number> = {}
|
||||
let bundle = ''
|
||||
let ended = false
|
||||
|
||||
const finish = (): void => {
|
||||
if (!current) return
|
||||
const payloads = (current.payloads as string[]).map((p) => rtpmap.get(p) ?? p)
|
||||
// The codec names once each: VP8 appears with its retransmission and
|
||||
// forward-error-correction companions and listing all of them is noise.
|
||||
const names = [...new Set(payloads.map((p) => p.split('/')[0]).filter((n) => !/^(rtx|red|ulpfec|flexfec-03|telephone-event|CN)$/i.test(n)))]
|
||||
current.codecs = names
|
||||
delete current.payloads
|
||||
sections.push(current)
|
||||
current = null
|
||||
rtpmap.clear()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('m=')) {
|
||||
finish()
|
||||
const [kind, port, , ...payloads] = line.slice(2).split(' ')
|
||||
current = { kind, port: Number(port), payloads, dir: 'sendrecv' }
|
||||
} else if (line.startsWith('a=group:BUNDLE')) {
|
||||
bundle = line.slice('a=group:BUNDLE'.length).trim()
|
||||
} else if (line.startsWith('a=end-of-candidates')) {
|
||||
ended = true
|
||||
} else if (current) {
|
||||
const m = /^a=(sendrecv|sendonly|recvonly|inactive)$/.exec(line)
|
||||
if (m) current.dir = m[1]
|
||||
else if (line.startsWith('a=mid:')) current.mid = line.slice(6)
|
||||
else if (line.startsWith('a=msid:')) current.msid = line.slice(7).split(' ')[0].slice(0, 12)
|
||||
else if (line.startsWith('a=rtpmap:')) {
|
||||
const [pt, name] = line.slice(9).split(' ')
|
||||
rtpmap.set(pt, name)
|
||||
} else if (line.startsWith('a=candidate:')) {
|
||||
const type = /typ (\w+)/.exec(line)?.[1] ?? '?'
|
||||
candidates[type] = (candidates[type] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
return {
|
||||
sections: sections.map((s) => `${s.kind}${s.mid !== undefined ? `#${s.mid}` : ''} ${s.dir}${s.port === 0 ? ' REFUSED' : ''} [${(s.codecs as string[]).join(',')}]${s.msid ? ` msid=${s.msid}` : ''}`),
|
||||
bundle,
|
||||
...(Object.keys(candidates).length ? { candidatesInSdp: candidates, endOfCandidates: ended } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The relays and stun servers a call was given, by address only. */
|
||||
export function describeIce(servers: RTCIceServer[]): string[] {
|
||||
return servers.flatMap((s) => (Array.isArray(s.urls) ? s.urls : [s.urls])).map((u) => u.replace(/^(\w+:)/, '$1'))
|
||||
}
|
||||
|
||||
interface Counters {
|
||||
bytes: number
|
||||
packets: number
|
||||
frames: number
|
||||
}
|
||||
|
||||
const previous = new Map<string, Counters>()
|
||||
|
||||
/**
|
||||
* A reading of what is actually crossing the connection.
|
||||
*
|
||||
* The chosen path (a relay, or a direct route) and, per direction and kind,
|
||||
* how much has moved since the last reading. "Video track exists, no bytes in
|
||||
* five seconds" is a network or a sender fault; "bytes arriving, no frames
|
||||
* decoded" is a codec fault; "no bytes leaving" is this end. Those are the
|
||||
* distinctions a silent picture needs made.
|
||||
*/
|
||||
export async function sampleStats(callId: string, pc: RTCPeerConnection): Promise<Record<string, unknown>> {
|
||||
const report = await pc.getStats()
|
||||
const byId = new Map<string, any>()
|
||||
report.forEach((s) => byId.set(s.id, s))
|
||||
const out: Record<string, unknown> = {
|
||||
ice: pc.iceConnectionState,
|
||||
conn: pc.connectionState,
|
||||
signalling: pc.signalingState
|
||||
}
|
||||
report.forEach((s) => {
|
||||
if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') {
|
||||
const l = byId.get(s.localCandidateId)
|
||||
const r = byId.get(s.remoteCandidateId)
|
||||
out.path = `${l?.candidateType}/${l?.protocol} -> ${r?.candidateType}/${r?.protocol}`
|
||||
if (typeof s.currentRoundTripTime === 'number') out.rttMs = Math.round(s.currentRoundTripTime * 1000)
|
||||
}
|
||||
if (s.type === 'transport') out.dtls = s.dtlsState
|
||||
const inbound = s.type === 'inbound-rtp'
|
||||
const outbound = s.type === 'outbound-rtp'
|
||||
if (!inbound && !outbound) return
|
||||
const kind = s.kind ?? s.mediaType
|
||||
const key = `${callId}|${s.id}`
|
||||
const now: Counters = {
|
||||
bytes: inbound ? s.bytesReceived ?? 0 : s.bytesSent ?? 0,
|
||||
packets: inbound ? s.packetsReceived ?? 0 : s.packetsSent ?? 0,
|
||||
frames: inbound ? s.framesDecoded ?? 0 : s.framesEncoded ?? 0
|
||||
}
|
||||
const before = previous.get(key) ?? { bytes: 0, packets: 0, frames: 0 }
|
||||
previous.set(key, now)
|
||||
const entry: Record<string, unknown> = {
|
||||
bytes: now.bytes,
|
||||
new: now.bytes - before.bytes,
|
||||
...(kind === 'video' ? { frames: now.frames, newFrames: now.frames - before.frames } : {})
|
||||
}
|
||||
const codec = s.codecId ? byId.get(s.codecId)?.mimeType : undefined
|
||||
if (codec) entry.codec = codec
|
||||
if (kind === 'video' && s.frameWidth) entry.size = `${s.frameWidth}x${s.frameHeight}`
|
||||
if (inbound && typeof s.packetsLost === 'number') entry.lost = s.packetsLost
|
||||
if (outbound && kind === 'video' && s.qualityLimitationReason && s.qualityLimitationReason !== 'none') {
|
||||
entry.limited = s.qualityLimitationReason
|
||||
}
|
||||
out[`${inbound ? 'in' : 'out'}.${kind}`] = entry
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Forget a call's counters. */
|
||||
export function forgetStats(callId: string): void {
|
||||
for (const key of previous.keys()) if (key.startsWith(`${callId}|`)) previous.delete(key)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Whether a capture is actually producing a picture.
|
||||
*
|
||||
* A screen opened through the desktop portal on Wayland is handed back as a
|
||||
* live track at once - before anybody has answered the portal's own "what do
|
||||
* you want to share" window - and if that window is cancelled, or never
|
||||
* noticed, the track stays live for ever and delivers nothing. It does not
|
||||
* end and it does not fail. So a returned stream says nothing about whether
|
||||
* there is a screen behind it; only a frame does.
|
||||
*
|
||||
* Resolves true on the first frame and false if the track ends or nothing
|
||||
* arrives in time, which is what "the person backed out" looks like from
|
||||
* here. The time allowed is a person's time - they are choosing a window -
|
||||
* not a network's.
|
||||
*/
|
||||
export function firstFrame(stream: MediaStream, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (!track) return resolve(false)
|
||||
|
||||
const probe = document.createElement('video')
|
||||
probe.muted = true
|
||||
probe.srcObject = stream
|
||||
|
||||
let settled = false
|
||||
const finish = (arrived: boolean): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
track.removeEventListener('ended', onEnded)
|
||||
probe.srcObject = null
|
||||
resolve(arrived)
|
||||
}
|
||||
const onEnded = (): void => finish(false)
|
||||
const timer = setTimeout(() => finish(false), timeoutMs)
|
||||
track.addEventListener('ended', onEnded)
|
||||
|
||||
if (probe.requestVideoFrameCallback) probe.requestVideoFrameCallback(() => finish(true))
|
||||
else probe.onloadeddata = () => finish(true)
|
||||
void probe.play().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
/** How long somebody is given to choose what to share. */
|
||||
export const SCREEN_CHOICE_MS = 60_000
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { NobilisEvent } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Somebody else's Go Live stream, decoded and drawn.
|
||||
*
|
||||
* The mirror of `discordscreen.ts`. That one takes frames off a capture,
|
||||
* encodes them and hands them to the daemon; this one takes frames from the
|
||||
* daemon, decodes them and draws them. Both live here rather than in the
|
||||
* daemon for the same reason: Chromium has the codecs and the daemon has
|
||||
* none, so the process with a window in it is the one that can do this at all.
|
||||
*
|
||||
* VP8 only, because that is what the sending side agreed with Discord and
|
||||
* what every machine can decode in software - a stream that plays on one
|
||||
* desktop and not the next is worse than one that is a little larger on the
|
||||
* wire.
|
||||
*/
|
||||
|
||||
export interface StreamWatch {
|
||||
/** Stops decoding and releases the decoder. Safe to call twice. */
|
||||
stop: () => void
|
||||
/** Where the picture is drawn. Sized to the stream once one arrives. */
|
||||
canvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts decoding a stream into a canvas.
|
||||
*
|
||||
* Nothing is asked of the daemon here - the caller has already asked to
|
||||
* watch, and frames arrive as events whether or not anything is drawing them.
|
||||
* This subscribes, decodes and draws until stopped.
|
||||
*/
|
||||
export function watchStream(
|
||||
accountId: string,
|
||||
streamKey: string,
|
||||
canvas: HTMLCanvasElement,
|
||||
onError: (message: string) => void
|
||||
): StreamWatch {
|
||||
const context = canvas.getContext('2d')
|
||||
let stopped = false
|
||||
/**
|
||||
* Whether the decoder has been given a keyframe yet.
|
||||
*
|
||||
* The daemon drops everything before the first one, so in practice the
|
||||
* first frame to arrive is a keyframe - but a decoder configured and then
|
||||
* fed a delta frame throws rather than waits, and a stream that recovers by
|
||||
* itself after a hiccup is worth more than one that needs reopening.
|
||||
*/
|
||||
let started = false
|
||||
|
||||
const decoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
try {
|
||||
if (stopped) return
|
||||
// Sized from the picture rather than from the layout: the sender
|
||||
// chooses the resolution and a canvas fixed at some other size would
|
||||
// resample every frame for nothing.
|
||||
if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
|
||||
canvas.width = frame.displayWidth
|
||||
canvas.height = frame.displayHeight
|
||||
}
|
||||
context?.drawImage(frame, 0, 0)
|
||||
} finally {
|
||||
// Always, and before anything else can throw: a VideoFrame holds a
|
||||
// GPU buffer, and leaking one per frame exhausts the pool in seconds
|
||||
// and stops the stream with an error about nothing.
|
||||
frame.close()
|
||||
}
|
||||
},
|
||||
error: (e) => {
|
||||
if (stopped) return
|
||||
onError(`The decoder stopped: ${e.message}`)
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Codec only. Width and height come from the stream's own keyframe, so
|
||||
// nothing here has to be told them in advance or kept in step with the
|
||||
// sender when they change.
|
||||
decoder.configure({ codec: 'vp8', optimizeForLatency: true })
|
||||
|
||||
const unsubscribe = window.moho.onEvent((event: NobilisEvent) => {
|
||||
if (stopped || event.event !== 'discordStreamFrame') return
|
||||
const d = event.data as {
|
||||
accountId?: string
|
||||
streamKey?: string
|
||||
frame?: string
|
||||
keyframe?: boolean
|
||||
timestampMicros?: number
|
||||
ended?: boolean
|
||||
}
|
||||
if (d.accountId !== accountId || d.streamKey !== streamKey) return
|
||||
if (d.ended) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (!d.frame) return
|
||||
if (!started) {
|
||||
if (!d.keyframe) return
|
||||
started = true
|
||||
}
|
||||
try {
|
||||
decoder.decode(
|
||||
new EncodedVideoChunk({
|
||||
type: d.keyframe ? 'key' : 'delta',
|
||||
timestamp: d.timestampMicros ?? 0,
|
||||
data: fromBase64(d.frame)
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
// One bad frame is not the end of a stream. The next keyframe is
|
||||
// seconds away at most, and a decoder that gave up on the first
|
||||
// undecodable chunk would show a black rectangle for the rest of the
|
||||
// call.
|
||||
started = false
|
||||
console.warn('[discord stream] a frame would not decode:', (e as Error).message)
|
||||
}
|
||||
})
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
unsubscribe()
|
||||
// `close` on an already-closed decoder throws, and this is reached both
|
||||
// from the error path and from the caller.
|
||||
try {
|
||||
if (decoder.state !== 'closed') decoder.close()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
return { stop, canvas }
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 back to bytes.
|
||||
*
|
||||
* The frames cross the RPC boundary as text because that is what the event
|
||||
* bus carries. `atob` is the only decoder available in a renderer, and it
|
||||
* produces a string of char codes rather than bytes - so this is the
|
||||
* conversion, done once per frame rather than per byte where it can be.
|
||||
*/
|
||||
function fromBase64(text: string): Uint8Array {
|
||||
const binary = atob(text)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Moving pictures that arrive from the daemon as encoded VP8: somebody's
|
||||
* camera in a Discord call, somebody's Go Live stream.
|
||||
*
|
||||
* One decoder per picture, for as long as the picture lasts, fed from the
|
||||
* store's event handler from the very first frame - and any number of canvases
|
||||
* drawing what it produces. The alternative, a decoder per canvas, went black
|
||||
* whenever a canvas was replaced: moving somebody from the grid to the stage,
|
||||
* or the whole call into a window of its own, makes a new canvas, and a new
|
||||
* decoder can only begin at a keyframe - which a camera may not send again
|
||||
* for a long time, since nothing here can ask it for one.
|
||||
*
|
||||
* The latest picture is kept, so a canvas that arrives late shows it at once.
|
||||
*/
|
||||
|
||||
export interface FeedFrame {
|
||||
frame?: string
|
||||
keyframe?: boolean
|
||||
timestampMicros?: number
|
||||
ended?: boolean
|
||||
}
|
||||
|
||||
interface Feed {
|
||||
decoder: VideoDecoder
|
||||
/** The latest picture, kept for canvases that subscribe later. */
|
||||
latest: HTMLCanvasElement
|
||||
canvases: Set<HTMLCanvasElement>
|
||||
/** Whether a keyframe has been given to the decoder since it (re)started. */
|
||||
started: boolean
|
||||
}
|
||||
|
||||
const feeds = new Map<string, Feed>()
|
||||
|
||||
function draw(onto: HTMLCanvasElement, from: CanvasImageSource, width: number, height: number): void {
|
||||
if (onto.width !== width || onto.height !== height) {
|
||||
onto.width = width
|
||||
onto.height = height
|
||||
}
|
||||
onto.getContext('2d')?.drawImage(from, 0, 0)
|
||||
}
|
||||
|
||||
function open(key: string): Feed {
|
||||
const latest = document.createElement('canvas')
|
||||
const feed: Feed = {
|
||||
latest,
|
||||
canvases: new Set(),
|
||||
started: false,
|
||||
decoder: new VideoDecoder({
|
||||
output: (frame) => {
|
||||
try {
|
||||
// Sized from the picture: the sender chooses the resolution, and a
|
||||
// canvas fixed at another size would resample every frame for
|
||||
// nothing.
|
||||
draw(latest, frame, frame.displayWidth, frame.displayHeight)
|
||||
for (const canvas of feed.canvases) draw(canvas, latest, latest.width, latest.height)
|
||||
} finally {
|
||||
// Always: a VideoFrame holds a GPU buffer, and leaking one per
|
||||
// frame exhausts the pool in seconds.
|
||||
frame.close()
|
||||
}
|
||||
},
|
||||
error: (e) => {
|
||||
console.warn(`[frames] ${key}: the decoder stopped: ${e.message}`)
|
||||
close(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Codec only; the size comes from the keyframe.
|
||||
feed.decoder.configure({ codec: 'vp8', optimizeForLatency: true })
|
||||
feeds.set(key, feed)
|
||||
return feed
|
||||
}
|
||||
|
||||
function close(key: string): void {
|
||||
const feed = feeds.get(key)
|
||||
if (!feed) return
|
||||
feeds.delete(key)
|
||||
try {
|
||||
if (feed.decoder.state !== 'closed') feed.decoder.close()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
for (const canvas of feed.canvases) canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
/** One frame for the picture named `key`. */
|
||||
export function feedFrame(key: string, d: FeedFrame): void {
|
||||
if (d.ended) {
|
||||
close(key)
|
||||
return
|
||||
}
|
||||
if (!d.frame) return
|
||||
let feed = feeds.get(key)
|
||||
if (!feed) {
|
||||
// Only a keyframe can begin a picture.
|
||||
if (!d.keyframe) return
|
||||
feed = open(key)
|
||||
}
|
||||
if (!feed.started) {
|
||||
if (!d.keyframe) return
|
||||
feed.started = true
|
||||
}
|
||||
try {
|
||||
feed.decoder.decode(
|
||||
new EncodedVideoChunk({
|
||||
type: d.keyframe ? 'key' : 'delta',
|
||||
timestamp: d.timestampMicros ?? 0,
|
||||
data: fromBase64(d.frame)
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
// One bad frame is not the end of a picture; wait for the next keyframe.
|
||||
feed.started = false
|
||||
console.warn(`[frames] ${key}: a frame would not decode:`, (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws the picture named `key` into `canvas` until the returned function is called. */
|
||||
export function showFeed(key: string, canvas: HTMLCanvasElement): () => void {
|
||||
const feed = feeds.get(key)
|
||||
if (feed && feed.latest.width > 0) draw(canvas, feed.latest, feed.latest.width, feed.latest.height)
|
||||
// Subscribed even before the picture exists: the first keyframe then draws
|
||||
// straight into it.
|
||||
let target = feed
|
||||
const attach = (): void => {
|
||||
target = feeds.get(key)
|
||||
target?.canvases.add(canvas)
|
||||
}
|
||||
attach()
|
||||
// A feed opened after this canvas subscribed - the camera came on while its
|
||||
// tile was already showing - is picked up on the next look.
|
||||
const timer = window.setInterval(() => {
|
||||
if (feeds.get(key) !== target) attach()
|
||||
}, 500)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
for (const feed of feeds.values()) feed.canvases.delete(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every picture this account has open, for a call that has ended. */
|
||||
export function closeFeeds(prefix: string): void {
|
||||
for (const key of [...feeds.keys()]) if (key.startsWith(prefix)) close(key)
|
||||
}
|
||||
|
||||
export const cameraKey = (accountId: string, userId: string): string => `camera:${accountId}|${userId}`
|
||||
export const streamKey = (accountId: string, key: string): string => `stream:${accountId}|${key}`
|
||||
|
||||
/**
|
||||
* Base64 back to bytes. The frames cross the RPC boundary as text because
|
||||
* that is what the event bus carries.
|
||||
*/
|
||||
function fromBase64(text: string): Uint8Array {
|
||||
const binary = atob(text)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
@@ -234,12 +234,33 @@ export class SfuCall {
|
||||
return this.room.localParticipant.isCameraEnabled
|
||||
}
|
||||
|
||||
/** Shares a screen, or stops. Returns whether one is being shared. */
|
||||
async toggleScreen(): Promise<boolean> {
|
||||
const on = this.room.localParticipant.isScreenShareEnabled
|
||||
await this.room.localParticipant.setScreenShareEnabled(!on)
|
||||
/** Whether this end is sharing a screen. */
|
||||
get sharingScreen(): boolean {
|
||||
return this.room.localParticipant.isScreenShareEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares a screen that has already been opened, or stops with null.
|
||||
* Returns whether one is being shared.
|
||||
*
|
||||
* Opened by the caller rather than by LiveKit, so that a share here is
|
||||
* chosen and checked exactly as it is in a call between two people or on
|
||||
* Discord - one picker, and nothing published until a picture exists.
|
||||
*/
|
||||
async setScreen(stream: MediaStream | null): Promise<boolean> {
|
||||
const participant = this.room.localParticipant
|
||||
if (!stream) {
|
||||
for (const publication of [...participant.trackPublications.values()]) {
|
||||
if (publication.source === Track.Source.ScreenShare && publication.track) {
|
||||
await participant.unpublishTrack(publication.track, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (track) await participant.publishTrack(track, { source: Track.Source.ScreenShare, name: 'screen' })
|
||||
}
|
||||
this.local = this.ownStream()
|
||||
return !on
|
||||
return participant.isScreenShareEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { Call, type CallPhase } from './webrtc'
|
||||
import { callLog, describeSdp } from './calllog'
|
||||
import { SfuCall, type SfuParticipant } from './livekit'
|
||||
|
||||
/** What the daemon sends up when a call event arrives in a room. */
|
||||
@@ -677,6 +678,7 @@ export class MatrixCalls {
|
||||
handlers: {
|
||||
send: (signal) => {
|
||||
const base = { call_id: callId, version: '1', party_id: call.partyId }
|
||||
callLog(callId, 'signal-out', { kind: signal.kind, keys: Object.keys(signal.content) })
|
||||
// Each signal is its own event type; the shapes differ enough that
|
||||
// naming them here is clearer than a table.
|
||||
if (signal.kind === 'invite') {
|
||||
@@ -722,6 +724,24 @@ export class MatrixCalls {
|
||||
*/
|
||||
handle(event: MatrixCallEvent): void {
|
||||
const { kind, callId, content } = event
|
||||
// Everything that arrives, including what is about to be ignored: the
|
||||
// events a client does not answer are exactly the ones a fault hides in.
|
||||
// `metadata` is the stream description Element sends to say which stream
|
||||
// is a camera and which a screen; a peer that sends none is an older one.
|
||||
callLog(callId, 'signal-in', {
|
||||
kind,
|
||||
own: event.own,
|
||||
party: content.party_id ?? null,
|
||||
version: content.version ?? null,
|
||||
metadata: !!content.sdp_stream_metadata,
|
||||
// Whether a renegotiation is an offer or the answer to one, and what it
|
||||
// holds: the phone's changes to a call are only readable from this.
|
||||
...(content.description ? { description: content.description.type, ...describeSdp(content.description.sdp) } : {}),
|
||||
...(content.offer ? { offerSdp: describeSdp(content.offer.sdp) } : {}),
|
||||
...(content.answer ? { answerSdp: describeSdp(content.answer.sdp) } : {}),
|
||||
keys: Object.keys(content).filter((k) => k !== 'offer' && k !== 'answer' && k !== 'description' && k !== 'candidates'),
|
||||
active: this.active?.call.id === callId
|
||||
})
|
||||
|
||||
// A group call's own signalling, which is addressed rather than broadcast
|
||||
// even though every event in the room is seen by everybody.
|
||||
@@ -794,6 +814,12 @@ export class MatrixCalls {
|
||||
this.surface.onRinging(null)
|
||||
}
|
||||
if (this.active?.call.id === callId) this.end(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Anything else in the call namespace is ours to say nothing about. Logged
|
||||
// because `select_answer`, `reject` and a `negotiate` for a call this
|
||||
// window does not hold are the ones whose silence looks like a fault.
|
||||
callLog(callId, 'signal-unhandled', { kind, active: this.active?.call.id === callId })
|
||||
}
|
||||
}
|
||||
|
||||
+282
-20
@@ -19,6 +19,8 @@
|
||||
* needs from whatever is carrying it.
|
||||
*/
|
||||
|
||||
import { callLog, callLogEnd, describeIce, describeSdp, forgetStats, sampleStats } from './calllog'
|
||||
|
||||
/** What a call is doing, in the words a person would use. */
|
||||
export type CallPhase = 'ringing' | 'connecting' | 'connected' | 'ended'
|
||||
|
||||
@@ -38,6 +40,15 @@ export interface CallHandlers {
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a change to a call in progress waits for its answer.
|
||||
*
|
||||
* Short beside the ring timeout: the other end is already connected and
|
||||
* answering is a matter of milliseconds, so ten seconds of nothing means it
|
||||
* is not going to.
|
||||
*/
|
||||
const NEGOTIATE_TIMEOUT_MS = 10_000
|
||||
|
||||
/** How long to wait for an answer before giving up on an unanswered call. */
|
||||
const RING_TIMEOUT_MS = 60_000
|
||||
|
||||
@@ -81,6 +92,8 @@ export class Call {
|
||||
/** Media opened by somebody else and lent to this leg - see the
|
||||
* constructor. Never stopped here, because it is not this leg's to stop. */
|
||||
private shared: MediaStream | null = null
|
||||
/** Reads what is crossing the connection, for the log. */
|
||||
private statsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(opts: {
|
||||
id: string
|
||||
@@ -104,13 +117,29 @@ export class Call {
|
||||
this.partyId = `moho-${Math.random().toString(36).slice(2, 10)}`
|
||||
this.shared = opts.local ?? null
|
||||
this.pc = new RTCPeerConnection({ iceServers: opts.iceServers })
|
||||
callLog(this.id, 'created', {
|
||||
outgoing: this.outgoing,
|
||||
party: this.partyId,
|
||||
ice: describeIce(opts.iceServers),
|
||||
lentMedia: !!opts.local
|
||||
})
|
||||
this.watchConnection()
|
||||
|
||||
// Candidates are found for as long as a call lasts, not only at the
|
||||
// start: a network changing under a call is exactly when the later ones
|
||||
// matter, and a client that stopped listening after connecting would drop
|
||||
// the call rather than move it.
|
||||
this.pc.onicecandidate = (e) => {
|
||||
if (!e.candidate) return
|
||||
if (!e.candidate) {
|
||||
callLog(this.id, 'ice-gathered')
|
||||
return
|
||||
}
|
||||
// Only what kind of route it is - the address is not the log's to keep.
|
||||
callLog(this.id, 'ice-candidate', {
|
||||
type: e.candidate.type,
|
||||
protocol: e.candidate.protocol,
|
||||
...(e.candidate.type === 'relay' ? { relay: true } : {})
|
||||
})
|
||||
const candidate = {
|
||||
candidate: e.candidate.candidate,
|
||||
sdpMid: e.candidate.sdpMid,
|
||||
@@ -122,6 +151,13 @@ export class Call {
|
||||
}
|
||||
|
||||
this.pc.ontrack = (e) => {
|
||||
callLog(this.id, 'remote-track', {
|
||||
kind: e.track.kind,
|
||||
mid: e.transceiver.mid,
|
||||
muted: e.track.muted,
|
||||
streams: e.streams.length,
|
||||
direction: e.transceiver.currentDirection
|
||||
})
|
||||
for (const track of e.streams[0]?.getTracks() ?? [e.track]) {
|
||||
if (this.remote.getTracks().some((t) => t.id === track.id)) continue
|
||||
this.remote.addTrack(track)
|
||||
@@ -131,6 +167,7 @@ export class Call {
|
||||
// before it reports it as an end, and on a removed track the mute is
|
||||
// often all there is.
|
||||
const drop = (): void => {
|
||||
callLog(this.id, 'remote-track-gone', { kind: track.kind, state: track.readyState, muted: track.muted })
|
||||
this.remote.removeTrack(track)
|
||||
this.handlers.onRemoteStream(this.remote)
|
||||
}
|
||||
@@ -139,6 +176,8 @@ export class Call {
|
||||
// And back again when they turn it on: the same transceiver is
|
||||
// reused, so the track unmutes rather than a new one arriving.
|
||||
track.onunmute = () => {
|
||||
callLog(this.id, 'remote-track-live', { kind: track.kind })
|
||||
if (track.kind === 'video') this.sampleSoon('remote video live')
|
||||
if (!this.remote.getTracks().some((t) => t.id === track.id)) this.remote.addTrack(track)
|
||||
this.handlers.onRemoteStream(this.remote)
|
||||
}
|
||||
@@ -148,6 +187,7 @@ export class Call {
|
||||
|
||||
this.pc.onconnectionstatechange = () => {
|
||||
const state = this.pc.connectionState
|
||||
callLog(this.id, 'connection', { state })
|
||||
if (state === 'connected') this.setPhase('connected')
|
||||
// "failed" is over; "disconnected" is not, and often comes back on its
|
||||
// own when a network hiccups. Treating the second as the first is how
|
||||
@@ -194,9 +234,92 @@ export class Call {
|
||||
return !!this.camera && this.camera.readyState === 'live'
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the connection says about itself, and a reading of the media
|
||||
* every few seconds.
|
||||
*
|
||||
* The readings are the point. A call whose picture never arrives has a
|
||||
* connection that says "connected" all the way through; only counting what
|
||||
* crossed it says whether the fault is a route, a sender or a decoder.
|
||||
*/
|
||||
private watchConnection(): void {
|
||||
this.pc.oniceconnectionstatechange = () => callLog(this.id, 'ice', { state: this.pc.iceConnectionState })
|
||||
this.pc.onsignalingstatechange = () => callLog(this.id, 'signalling', { state: this.pc.signalingState })
|
||||
this.pc.onicegatheringstatechange = () => callLog(this.id, 'gathering', { state: this.pc.iceGatheringState })
|
||||
this.pc.onicecandidateerror = (e) => {
|
||||
const err = e as RTCPeerConnectionIceErrorEvent
|
||||
// Silent unless something went wrong: this is the one that says a relay
|
||||
// refused the credentials or could not be reached at all.
|
||||
callLog(this.id, 'ice-error', { url: err.url, code: err.errorCode, text: err.errorText })
|
||||
}
|
||||
this.statsTimer = setInterval(() => {
|
||||
if (this.phase === 'ended') return
|
||||
void sampleStats(this.id, this.pc)
|
||||
.then((reading) => callLog(this.id, 'stats', reading))
|
||||
.catch(() => {})
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading shortly after something changed.
|
||||
*
|
||||
* The regular one is every five seconds, and a screen share the other end
|
||||
* starts and stops inside that window is invisible to it - which is exactly
|
||||
* how the first phone share was lost to the log: it lasted two seconds. So a
|
||||
* track arriving, a change being answered, a capture opening each ask for
|
||||
* their own readings a moment later, when there is something to read.
|
||||
*/
|
||||
private sampleSoon(reason: string): void {
|
||||
for (const after of [1500, 4000]) {
|
||||
setTimeout(() => {
|
||||
if (this.phase === 'ended') return
|
||||
void sampleStats(this.id, this.pc)
|
||||
.then((reading) => callLog(this.id, 'stats', { after: reason, ...reading }))
|
||||
.catch(() => {})
|
||||
}, after)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether something captured here is producing a picture at all.
|
||||
*
|
||||
* A camera or a screen can open, report itself live, be negotiated and be
|
||||
* accepted by the other end, and still deliver no frames - a portal that is
|
||||
* waiting for a choice nobody sees, a device another program holds. From
|
||||
* outside that is a call in which the picture simply never appears, with
|
||||
* every indicator green. Counting frames for a few seconds is the only way
|
||||
* to tell it from one that works, and it turns silence into a sentence.
|
||||
*/
|
||||
private watchCapture(source: 'camera' | 'screen', track: MediaStreamTrack): void {
|
||||
const probe = document.createElement('video')
|
||||
probe.muted = true
|
||||
probe.srcObject = new MediaStream([track])
|
||||
let frames = 0
|
||||
const tick = (): void => {
|
||||
frames++
|
||||
probe.requestVideoFrameCallback?.(tick)
|
||||
}
|
||||
probe.requestVideoFrameCallback?.(tick)
|
||||
void probe.play().catch(() => {})
|
||||
setTimeout(() => {
|
||||
const width = probe.videoWidth
|
||||
probe.srcObject = null
|
||||
callLog(this.id, 'capture', { source, frames, width, state: track.readyState })
|
||||
if (frames === 0 && track.readyState === 'live') {
|
||||
this.handlers.onError(
|
||||
source === 'screen'
|
||||
? 'The shared screen has stopped producing a picture - the other end will see nothing'
|
||||
: 'The camera opened but is producing no picture'
|
||||
)
|
||||
}
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
private setPhase(phase: CallPhase): void {
|
||||
if (this.phase === phase || this.phase === 'ended') return
|
||||
this.phase = phase
|
||||
callLog(this.id, 'phase', { phase })
|
||||
if (phase === 'ended' && this.statsTimer) clearInterval(this.statsTimer)
|
||||
this.handlers.onPhase(phase)
|
||||
}
|
||||
|
||||
@@ -219,8 +342,10 @@ export class Call {
|
||||
const audio = { echoCancellation: true, noiseSuppression: true }
|
||||
try {
|
||||
this.local = await navigator.mediaDevices.getUserMedia({ audio, video })
|
||||
callLog(this.id, 'local-media', { wantedVideo: video, ...describeStream(this.local) })
|
||||
} catch (e) {
|
||||
const name = (e as Error).name
|
||||
callLog(this.id, 'local-media-failed', { wantedVideo: video, name, message: (e as Error).message })
|
||||
const noCamera = name === 'NotFoundError' || name === 'NotAllowedError' || name === 'OverconstrainedError'
|
||||
if (!video || !noCamera) throw e
|
||||
this.handlers.onError('No camera here - calling with audio only')
|
||||
@@ -235,6 +360,7 @@ export class Call {
|
||||
await this.openLocal(video)
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
callLog(this.id, 'offer-made', describeSdp(offer.sdp))
|
||||
this.setPhase('ringing')
|
||||
this.handlers.send({
|
||||
kind: 'invite',
|
||||
@@ -254,10 +380,12 @@ export class Call {
|
||||
/** Answers a call that is ringing here. */
|
||||
async accept(offerSdp: string, video: boolean): Promise<void> {
|
||||
this.setPhase('connecting')
|
||||
callLog(this.id, 'offer-received', { answeringWithVideo: video, ...describeSdp(offerSdp) })
|
||||
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp })
|
||||
await this.openLocal(video)
|
||||
const answer = await this.pc.createAnswer()
|
||||
await this.pc.setLocalDescription(answer)
|
||||
callLog(this.id, 'answer-made', describeSdp(answer.sdp))
|
||||
this.answered = true
|
||||
this.handlers.send({ kind: 'answer', content: { answer: { type: 'answer', sdp: answer.sdp } } })
|
||||
this.flushCandidates()
|
||||
@@ -269,6 +397,7 @@ export class Call {
|
||||
this.answered = true
|
||||
if (this.ringTimer) clearTimeout(this.ringTimer)
|
||||
this.setPhase('connecting')
|
||||
callLog(this.id, 'answer-received', describeSdp(answerSdp))
|
||||
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
|
||||
this.flushCandidates()
|
||||
}
|
||||
@@ -322,6 +451,7 @@ export class Call {
|
||||
*/
|
||||
async setCamera(on: boolean, source?: MediaStream | null): Promise<boolean> {
|
||||
if (on === this.cameraOn) return this.cameraOn
|
||||
callLog(this.id, 'camera', { turning: on ? 'on' : 'off' })
|
||||
|
||||
if (!on) {
|
||||
const track = this.camera
|
||||
@@ -343,10 +473,17 @@ export class Call {
|
||||
// Its own request rather than reopening the microphone with it: the
|
||||
// microphone is already open and already being listened to, and asking
|
||||
// for both again would interrupt the audio to add a picture.
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
} catch (e) {
|
||||
callLog(this.id, 'camera-failed', { name: (e as Error).name, message: (e as Error).message })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (!track) return false
|
||||
callLog(this.id, 'camera-opened', describeStream(stream))
|
||||
this.watchCapture('camera', track)
|
||||
this.camera = track
|
||||
// The browser's own "stop" - a camera unplugged, a privacy shutter, the
|
||||
// device taken by something else - has to reach the call, or the far end
|
||||
@@ -368,6 +505,7 @@ export class Call {
|
||||
*/
|
||||
async shareScreen(source?: MediaStream): Promise<boolean> {
|
||||
if (this.screen) {
|
||||
callLog(this.id, 'screen', { turning: 'off' })
|
||||
this.screen.stop()
|
||||
const sender = this.pc.getSenders().find((s) => s.track === this.screen)
|
||||
if (sender) this.pc.removeTrack(sender)
|
||||
@@ -375,9 +513,18 @@ export class Call {
|
||||
await this.renegotiate()
|
||||
return false
|
||||
}
|
||||
const stream = source ?? (await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }))
|
||||
callLog(this.id, 'screen', { turning: 'on' })
|
||||
let stream: MediaStream
|
||||
try {
|
||||
stream = source ?? (await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }))
|
||||
} catch (e) {
|
||||
callLog(this.id, 'screen-failed', { name: (e as Error).name, message: (e as Error).message })
|
||||
throw e
|
||||
}
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (!track) return false
|
||||
callLog(this.id, 'screen-opened', describeStream(stream))
|
||||
this.watchCapture('screen', track)
|
||||
this.screen = track
|
||||
// Stopping from the browser's own "stop sharing" control has to reach the
|
||||
// call too, or the other end keeps a frozen last frame.
|
||||
@@ -398,14 +545,63 @@ export class Call {
|
||||
* have to happen.
|
||||
*/
|
||||
private pendingNegotiation: Promise<void> = Promise.resolve()
|
||||
/** Offers from the other end, one at a time, and not behind our own. */
|
||||
private incomingOffers: Promise<void> = Promise.resolve()
|
||||
/** Set while one of our offers is out and its answer is awaited. */
|
||||
private answerWaiter: (() => void) | null = null
|
||||
/** An offer of ours was given up for theirs, and still has to be made. */
|
||||
private owesOffer = false
|
||||
|
||||
/**
|
||||
* Waits for the answer to the offer just sent.
|
||||
*
|
||||
* This is what "one at a time" has to mean. The chain used to move on the
|
||||
* moment an offer was *sent*, so turning a camera on and starting a share a
|
||||
* moment later put two offers in flight; the far end answered both, and the
|
||||
* second answer arrived in a connection that had already settled and was
|
||||
* refused. Waiting for the answer is the only way there is ever one.
|
||||
*
|
||||
* With a limit, because some clients never answer a change at all - and
|
||||
* "the other end ignored it" is exactly the thing worth being able to see,
|
||||
* so it is logged rather than waited on for ever.
|
||||
*/
|
||||
private waitForAnswer(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.answerWaiter = null
|
||||
callLog(this.id, 'negotiate-unanswered', { afterMs: NEGOTIATE_TIMEOUT_MS, signalling: this.pc.signalingState })
|
||||
this.handlers.onError("The other end didn't answer the change to the call")
|
||||
// Our offer is still standing; leaving it there would refuse every
|
||||
// change that came after.
|
||||
this.pc.setLocalDescription({ type: 'rollback' }).catch(() => {}).finally(resolve)
|
||||
}, NEGOTIATE_TIMEOUT_MS)
|
||||
this.answerWaiter = () => {
|
||||
clearTimeout(timer)
|
||||
this.answerWaiter = null
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private renegotiate(): Promise<void> {
|
||||
this.pendingNegotiation = this.pendingNegotiation.then(async () => {
|
||||
if (this.phase === 'ended') return
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
this.handlers.send({ kind: 'negotiate', content: { description: { type: 'offer', sdp: offer.sdp } } })
|
||||
})
|
||||
this.pendingNegotiation = this.pendingNegotiation
|
||||
.then(async () => {
|
||||
if (this.phase === 'ended') return
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
callLog(this.id, 'negotiate-offer-made', describeSdp(offer.sdp))
|
||||
this.handlers.send({ kind: 'negotiate', content: { description: { type: 'offer', sdp: offer.sdp } } })
|
||||
await this.waitForAnswer()
|
||||
})
|
||||
// Caught here, and not passed on. A rejected promise left in this chain
|
||||
// is a rejected promise every later step is chained onto, and a `then`
|
||||
// on one never runs - so a single failed renegotiation used to switch
|
||||
// off camera and screen sharing for the rest of the call, without a
|
||||
// word about why. The failure is logged and the chain carries on.
|
||||
.catch((e: Error) => {
|
||||
callLog(this.id, 'negotiate-failed', { direction: 'ours', name: e.name, message: e.message, signalling: this.pc.signalingState })
|
||||
this.handlers.onError(`Couldn't change the call: ${e.message}`)
|
||||
})
|
||||
return this.pendingNegotiation
|
||||
}
|
||||
|
||||
@@ -413,24 +609,76 @@ export class Call {
|
||||
* A renegotiation from the other end - somebody turning a camera on, or
|
||||
* starting to share.
|
||||
*
|
||||
* Queued behind this end's own, for the reason above: two descriptions
|
||||
* being applied to one connection at once is how a call ends up describing
|
||||
* something neither end is sending.
|
||||
* An answer goes straight to the offer waiting for it; only offers queue.
|
||||
* They cannot queue behind our own change, which is itself waiting for the
|
||||
* answer these are holding up.
|
||||
*
|
||||
* Two ends offering at once - a camera pressed on both sides in the same
|
||||
* second - is settled the way every other client settles it: the end that
|
||||
* made the call ignores the other's offer, and the end that answered it
|
||||
* gives its own up, takes theirs, and offers again afterwards. Which is the
|
||||
* one that yields is a property of the call, not of who was quicker, so both
|
||||
* ends reach the same answer without talking.
|
||||
*/
|
||||
takeNegotiation(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
this.pendingNegotiation = this.pendingNegotiation.then(async () => {
|
||||
if (this.phase === 'ended') return
|
||||
if (description.type === 'answer') return this.takeNegotiatedAnswer(description)
|
||||
|
||||
this.incomingOffers = this.incomingOffers
|
||||
.then(async () => {
|
||||
if (this.phase === 'ended') return
|
||||
const collision = this.pc.signalingState !== 'stable'
|
||||
callLog(this.id, 'negotiate-received', {
|
||||
type: description.type,
|
||||
signalling: this.pc.signalingState,
|
||||
...(collision ? { glare: this.outgoing ? 'ignored (we made the call)' : 'yielding (we answered the call)' } : {}),
|
||||
...describeSdp(description.sdp)
|
||||
})
|
||||
if (collision && this.outgoing) return
|
||||
if (collision) {
|
||||
this.owesOffer = true
|
||||
this.answerWaiter?.()
|
||||
}
|
||||
await this.pc.setRemoteDescription(description)
|
||||
const answer = await this.pc.createAnswer()
|
||||
await this.pc.setLocalDescription(answer)
|
||||
callLog(this.id, 'negotiate-answer-made', describeSdp(answer.sdp))
|
||||
this.handlers.send({ kind: 'negotiate', content: { description: { type: 'answer', sdp: answer.sdp } } })
|
||||
this.sampleSoon('their change answered')
|
||||
if (this.owesOffer) {
|
||||
this.owesOffer = false
|
||||
void this.renegotiate()
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
callLog(this.id, 'negotiate-failed', { direction: 'theirs', name: e.name, message: e.message, signalling: this.pc.signalingState })
|
||||
this.handlers.onError(`The other end's change to the call failed: ${e.message}`)
|
||||
})
|
||||
return this.incomingOffers
|
||||
}
|
||||
|
||||
private async takeNegotiatedAnswer(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
callLog(this.id, 'negotiate-answer-received', { signalling: this.pc.signalingState, ...describeSdp(description.sdp) })
|
||||
try {
|
||||
// An answer to an offer that is no longer standing - ours was given up
|
||||
// in a collision, or timed out - has nothing to answer.
|
||||
if (this.pc.signalingState !== 'have-local-offer') {
|
||||
callLog(this.id, 'negotiate-answer-stale', { signalling: this.pc.signalingState })
|
||||
return
|
||||
}
|
||||
await this.pc.setRemoteDescription(description)
|
||||
if (description.type !== 'offer') return
|
||||
const answer = await this.pc.createAnswer()
|
||||
await this.pc.setLocalDescription(answer)
|
||||
this.handlers.send({ kind: 'negotiate', content: { description: { type: 'answer', sdp: answer.sdp } } })
|
||||
})
|
||||
return this.pendingNegotiation
|
||||
this.sampleSoon('our change answered')
|
||||
} catch (e) {
|
||||
callLog(this.id, 'negotiate-failed', { direction: 'answer', name: (e as Error).name, message: (e as Error).message, signalling: this.pc.signalingState })
|
||||
this.handlers.onError(`The other end's answer failed: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.answerWaiter?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the call, telling the other end unless it was them who ended it. */
|
||||
async hangUp(reason?: string, tell = true): Promise<void> {
|
||||
callLog(this.id, 'hangup', { reason: reason ?? null, toldThem: tell, phase: this.phase })
|
||||
if (this.statsTimer) clearInterval(this.statsTimer)
|
||||
if (this.ringTimer) clearTimeout(this.ringTimer)
|
||||
if (tell) this.handlers.send({ kind: 'hangup', content: reason ? { reason } : {} })
|
||||
// Not the shared capture: other legs of the same call are still using it,
|
||||
@@ -446,5 +694,19 @@ export class Call {
|
||||
this.camera = null
|
||||
this.pc.close()
|
||||
this.setPhase('ended')
|
||||
forgetStats(this.id)
|
||||
callLogEnd(this.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** What a capture is, in a line: the tracks it holds and what they are. */
|
||||
function describeStream(stream: MediaStream): Record<string, unknown> {
|
||||
return {
|
||||
tracks: stream.getTracks().map((t) => {
|
||||
const settings = t.getSettings()
|
||||
return t.kind === 'video'
|
||||
? `video ${settings.width ?? '?'}x${settings.height ?? '?'}@${Math.round(settings.frameRate ?? 0)} ${t.readyState}${t.label ? ` "${t.label.slice(0, 40)}"` : ''}`
|
||||
: `audio ${t.readyState}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+204
-50
@@ -30,6 +30,8 @@ import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
|
||||
import { MatrixCalls, type CallMember, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
|
||||
import type { CallPhase } from '../lib/webrtc'
|
||||
import { Levels } from '../lib/levels'
|
||||
import { firstFrame, SCREEN_CHOICE_MS } from '../lib/capture'
|
||||
import { cameraKey, closeFeeds, feedFrame, streamKey as feedStreamKey } from '../lib/framefeed'
|
||||
import type { PopoutState } from '../../../shared/ipc'
|
||||
|
||||
/**
|
||||
@@ -316,6 +318,19 @@ export interface DiscordModal {
|
||||
|
||||
export type ActivePanel = '' | 'accounts' | 'settings' | 'join' | 'downloads'
|
||||
|
||||
/** A room being looked into without having been joined. */
|
||||
export interface PeekTarget {
|
||||
accountId: string
|
||||
roomId: string
|
||||
alias: string
|
||||
name: string
|
||||
/** The homeserver that listed it, which is where to ask about it. */
|
||||
via: string
|
||||
joinRule?: string
|
||||
/** Set once Join has been pressed, until the room arrives or the join fails. */
|
||||
joining?: boolean
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
/** Who is composing, per buffer, with when to stop believing it. */
|
||||
typingByBuffer: Record<string, { nicks: string[]; until: number }>
|
||||
@@ -431,6 +446,11 @@ export interface ChatState {
|
||||
/** Set in a popped-out window, naming the one conversation it may show. */
|
||||
pinnedBufferId: string
|
||||
activePanel: ActivePanel
|
||||
/**
|
||||
* The room being looked at, if any. Shown in the conversation's own pane in
|
||||
* place of a conversation, and gone the moment one is selected.
|
||||
*/
|
||||
peek: PeekTarget | null
|
||||
joinPanelAccountId: string
|
||||
messagesByBuffer: Record<string, ChatMessage[]>
|
||||
presenceByBuffer: Record<string, Member[]>
|
||||
@@ -488,6 +508,8 @@ export interface ChatState {
|
||||
ringingCall: RingingCall | null
|
||||
/** The call put away without being hung up. */
|
||||
callMinimized: boolean
|
||||
/** The call is drawn in a window of its own rather than over the conversation. */
|
||||
callPoppedOut: boolean
|
||||
/**
|
||||
* Who is in each room's call, by buffer.
|
||||
*
|
||||
@@ -526,6 +548,12 @@ export interface ChatState {
|
||||
* key is what watching one is asked for by.
|
||||
*/
|
||||
discordStreams: Record<string, string>
|
||||
/**
|
||||
* Whose camera is on in a Discord call, as `accountId|userId`. Set by the
|
||||
* first frame and cleared by the `ended` one - not touched in between, so
|
||||
* thirty frames a second do not redraw the window.
|
||||
*/
|
||||
discordCameras: Record<string, true>
|
||||
/** The stream being watched, if any. One at a time, deliberately. */
|
||||
discordWatching: { accountId: string; streamKey: string; userId: string; nick: string } | null
|
||||
activeCall: {
|
||||
@@ -610,6 +638,7 @@ const INITIAL: ChatState = {
|
||||
popouts: { open: [], watched: [] },
|
||||
pinnedBufferId: '',
|
||||
activePanel: '',
|
||||
peek: null,
|
||||
joinPanelAccountId: '',
|
||||
messagesByBuffer: {},
|
||||
presenceByBuffer: {},
|
||||
@@ -630,6 +659,7 @@ const INITIAL: ChatState = {
|
||||
discordFriends: {},
|
||||
ringingCall: null,
|
||||
callMinimized: false,
|
||||
callPoppedOut: false,
|
||||
callMembers: {},
|
||||
watching: null,
|
||||
watchMinimized: false,
|
||||
@@ -638,6 +668,7 @@ const INITIAL: ChatState = {
|
||||
activeCall: null,
|
||||
discordSharing: false,
|
||||
discordStreams: {},
|
||||
discordCameras: {},
|
||||
discordWatching: null,
|
||||
ignoredByAccount: {},
|
||||
matrixWidgets: {},
|
||||
@@ -903,15 +934,28 @@ export class ChatStore {
|
||||
},
|
||||
iceServers: async (accountId) => {
|
||||
try {
|
||||
const answer = await window.moho.rpc<{ uris?: string[]; username?: string; password?: string }>(
|
||||
'matrixTurnServers',
|
||||
{ accountId }
|
||||
)
|
||||
const answer = await window.moho.rpc<{
|
||||
uris?: string[]
|
||||
username?: string
|
||||
password?: string
|
||||
fallback?: string[]
|
||||
}>('matrixTurnServers', { accountId })
|
||||
const servers: RTCIceServer[] = []
|
||||
const uris = answer.uris ?? []
|
||||
if (uris.length === 0) return []
|
||||
// One entry carrying every uri, which is how a browser wants them:
|
||||
// the credentials are the same for all of a homeserver's relays.
|
||||
return [{ urls: uris, username: answer.username, credential: answer.password }]
|
||||
if (uris.length > 0) servers.push({ urls: uris, username: answer.username, credential: answer.password })
|
||||
// A public STUN server as well, unless it has been switched off. It is
|
||||
// listed apart from the relay and never given its credentials: it is a
|
||||
// mirror that says what this address looks like from outside, and it
|
||||
// is what gets a call across two networks when the homeserver's own
|
||||
// relay is missing - or is advertised and answers nobody, which looks
|
||||
// the same from here.
|
||||
const prefs = await window.moho.prefs.getAll()
|
||||
if (prefs['matrix.fallbackStun'] !== false && (answer.fallback?.length ?? 0) > 0) {
|
||||
servers.push({ urls: answer.fallback! })
|
||||
}
|
||||
return servers
|
||||
} catch {
|
||||
// No relay is a working configuration for two people on the same
|
||||
// network, and a call worth trying is better than a refusal.
|
||||
@@ -1048,6 +1092,10 @@ export class ChatStore {
|
||||
|
||||
const stored = await window.moho.prefs.getAll()
|
||||
this.set({ lastReadTs: (stored.lastReadTs as Record<string, number>) ?? {} })
|
||||
// The picker is drawn here but asked for by the main process, halfway
|
||||
// through a capture request - see main/screenshare.ts. Every window
|
||||
// registers, because the question goes to whichever one asked to share.
|
||||
window.moho.onScreenPick(async (sources) => (await this.askForScreenSource(sources))?.id ?? null)
|
||||
|
||||
window.moho.onLinkChange((up) => {
|
||||
this.set({ linkUp: up })
|
||||
@@ -1768,7 +1816,7 @@ export class ChatStore {
|
||||
|
||||
selectGroup(groupId: string): void {
|
||||
if (groupId === this.state.activeGroupId) return
|
||||
this.set({ activeGroupId: groupId })
|
||||
this.set({ activeGroupId: groupId, peek: null })
|
||||
if (!this.state.pinnedBufferId) void window.moho.prefs.set('ui.activeGroupId', groupId)
|
||||
}
|
||||
|
||||
@@ -2067,6 +2115,11 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
/** Puts the call away, or brings it back. It keeps running either way. */
|
||||
/** Moves the call into a window of its own, or back over the conversation. */
|
||||
setCallPoppedOut(poppedOut: boolean): void {
|
||||
this.set({ callPoppedOut: poppedOut })
|
||||
}
|
||||
|
||||
setCallMinimized(minimized: boolean): void {
|
||||
this.set({ callMinimized: minimized })
|
||||
}
|
||||
@@ -2206,12 +2259,46 @@ export class ChatStore {
|
||||
private askForScreenSource(
|
||||
sources: { id: string; name: string; thumbnail: string }[]
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
// A picker still open from an earlier request is answered "nothing"
|
||||
// rather than left waiting for ever behind this one.
|
||||
this.screenChoice?.(null)
|
||||
return new Promise((resolve) => {
|
||||
this.screenChoice = resolve
|
||||
this.set({ screenSources: sources })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a screen to share, or returns null if the person backed out.
|
||||
*
|
||||
* The same everywhere: Matrix, Matrix on a media server, and Discord all
|
||||
* come through here, on every desktop. Who chooses the screen - moho's own
|
||||
* picker or the desktop's - is the main process's business, answered inside
|
||||
* this one request (see main/screenshare.ts), so nothing here knows or asks
|
||||
* which desktop it is on.
|
||||
*
|
||||
* A stream coming back is not a screen being shared. Through the Wayland
|
||||
* portal it comes back live before the portal's window has been answered,
|
||||
* and stays live and empty for ever if that window is closed. So this waits
|
||||
* for a first frame, and only then is the share real; a choice never made
|
||||
* ends as a quiet "no" rather than as a share announced to a room and
|
||||
* showing nothing.
|
||||
*/
|
||||
private async openScreen(): Promise<MediaStream | null> {
|
||||
let stream: MediaStream
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||
} catch (e) {
|
||||
// Backing out of a picker is an answer, not a fault.
|
||||
if ((e as Error).name === 'NotAllowedError' || (e as Error).name === 'AbortError') return null
|
||||
throw e
|
||||
}
|
||||
if (await firstFrame(stream, SCREEN_CHOICE_MS)) return stream
|
||||
for (const track of stream.getTracks()) track.stop()
|
||||
this.toast('info', 'No screen was shared')
|
||||
return null
|
||||
}
|
||||
|
||||
/** The picker answering, with a source or with nothing. */
|
||||
chooseScreenSource(source: { id: string; name: string } | null): void {
|
||||
const resolve = this.screenChoice
|
||||
@@ -2375,9 +2462,8 @@ export class ChatStore {
|
||||
/**
|
||||
* Shares a screen or a window into the call.
|
||||
*
|
||||
* The picker is the desktop's rather than the browser's: Electron will not
|
||||
* answer getDisplayMedia without being told which source, and a list of the
|
||||
* windows somebody actually has open is the only way to choose one.
|
||||
* Opened by `openScreen`, which is where every share on every desktop
|
||||
* starts.
|
||||
*/
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
// A Discord call is not a Matrix one and has no activeCall behind it -
|
||||
@@ -2395,15 +2481,24 @@ export class ChatStore {
|
||||
return
|
||||
}
|
||||
if (!this.state.activeCall) return
|
||||
// On a media server the picker is LiveKit's own job, and Electron's
|
||||
// source list is what answers it - see the permission handler in main.
|
||||
// On a media server the screen is opened here, exactly as for the other
|
||||
// two, and handed to LiveKit to publish.
|
||||
const group = this.matrixCalls.currentGroup
|
||||
if (group?.sfu) {
|
||||
const sharing = await group.sfu.toggleScreen().catch((e: Error) => {
|
||||
this.toast('error', e.message)
|
||||
return false
|
||||
})
|
||||
this.set({ activeCall: { ...this.state.activeCall, sharingScreen: sharing } })
|
||||
const sfu = group.sfu
|
||||
try {
|
||||
let sharing: boolean
|
||||
if (sfu.sharingScreen) {
|
||||
sharing = await sfu.setScreen(null)
|
||||
} else {
|
||||
const stream = await this.openScreen()
|
||||
if (!stream) return
|
||||
sharing = await sfu.setScreen(stream)
|
||||
}
|
||||
if (this.state.activeCall) this.set({ activeCall: { ...this.state.activeCall, sharingScreen: sharing } })
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't share the screen: ${(e as Error).message}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const call = this.matrixCalls.current
|
||||
@@ -2414,23 +2509,8 @@ export class ChatStore {
|
||||
this.set({ activeCall: { ...this.state.activeCall, sharingScreen: false } })
|
||||
return
|
||||
}
|
||||
const sources = await window.moho.screenSources()
|
||||
if (sources.length === 0) {
|
||||
this.toast('info', 'Nothing to share')
|
||||
return
|
||||
}
|
||||
// Chosen in the window rather than here: the picker is a component, and
|
||||
// this waits for it to answer.
|
||||
const source = await this.askForScreenSource(sources)
|
||||
if (!source) return
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
// Chromium's own desktop-capture constraint, which is how an
|
||||
// Electron window turns a source id into a stream.
|
||||
mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: source.id }
|
||||
}
|
||||
} as unknown as MediaStreamConstraints)
|
||||
const stream = await this.openScreen()
|
||||
if (!stream) return
|
||||
await call.call.shareScreen(stream)
|
||||
this.set({ activeCall: { ...this.state.activeCall, sharingScreen: true } })
|
||||
} catch (e) {
|
||||
@@ -2458,15 +2538,12 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
try {
|
||||
const sources = await window.moho.screenSources()
|
||||
if (sources.length === 0) {
|
||||
this.toast('info', 'Nothing to share')
|
||||
return
|
||||
}
|
||||
// The same picker the Matrix path uses, for the same reason: Electron
|
||||
// will not answer getDisplayMedia without being told which source.
|
||||
const source = await this.askForScreenSource(sources)
|
||||
if (!source) return
|
||||
// The screen first, then Discord. The person chooses while the click
|
||||
// that asked is still fresh - a browser will not open a capture for a
|
||||
// gesture spent on a twenty-second wait for a server - and a share
|
||||
// they back out of never costs a stream connection.
|
||||
const stream = await this.openScreen()
|
||||
if (!stream) return
|
||||
|
||||
await window.moho.rpc('startDiscordScreenShare', { bufferId })
|
||||
this.toast('info', 'Setting up the stream…')
|
||||
@@ -2482,15 +2559,11 @@ export class ChatStore {
|
||||
// or a server it could not find is a sentence somebody can act on.
|
||||
this.toast('error', ready.error ? `Couldn’t open the stream: ${ready.error}` : 'Discord never opened the stream')
|
||||
await window.moho.rpc('stopDiscordScreenShare', { bufferId }).catch(() => {})
|
||||
// A capture already open has nobody to send it to.
|
||||
for (const track of stream.getTracks()) track.stop()
|
||||
return
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: source.id }
|
||||
}
|
||||
} as unknown as MediaStreamConstraints)
|
||||
const { shareScreen } = await import('../lib/discordscreen')
|
||||
this.discordShare = await shareScreen(accountId, stream, (message) => {
|
||||
this.toast('error', message)
|
||||
@@ -2718,6 +2791,32 @@ export class ChatStore {
|
||||
break
|
||||
}
|
||||
|
||||
// Somebody's camera in a Discord call. The frames themselves are the
|
||||
// tile's business (see watchCamera); here only whether there is one.
|
||||
case 'discordCameraFrame': {
|
||||
const d = data as { accountId?: string; userId?: string; ended?: boolean }
|
||||
if (!d.accountId || !d.userId) break
|
||||
// Decoded here, from the first frame, for whichever tiles show it.
|
||||
feedFrame(cameraKey(d.accountId, d.userId), data as never)
|
||||
const key = `${d.accountId}|${d.userId}`
|
||||
const on = !!this.state.discordCameras[key]
|
||||
if (d.ended && on) {
|
||||
const cameras = { ...this.state.discordCameras }
|
||||
delete cameras[key]
|
||||
this.set({ discordCameras: cameras })
|
||||
} else if (!d.ended && !on) {
|
||||
this.set({ discordCameras: { ...this.state.discordCameras, [key]: true } })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// A Go Live stream being watched: decoded here for the tile to draw.
|
||||
case 'discordStreamFrame': {
|
||||
const d = data as { accountId?: string; streamKey?: string }
|
||||
if (d.accountId && d.streamKey) feedFrame(feedStreamKey(d.accountId, d.streamKey), data as never)
|
||||
break
|
||||
}
|
||||
|
||||
// How far an attachment's upload has got. Matched to the row by the
|
||||
// id the send named, so a second message sent while the first is
|
||||
// still uploading updates its own row rather than the newest one.
|
||||
@@ -3120,9 +3219,11 @@ export class ChatStore {
|
||||
// bumped). Merge the fresh server fields, keeping local-only unread and
|
||||
// highlight rather than resetting them.
|
||||
this.set({ buffers: buffers.map((b) => (b.id === data.id ? { ...b, ...data } : b)) })
|
||||
this.followPeekJoin(data)
|
||||
return
|
||||
}
|
||||
this.set({ buffers: [...buffers, { unread: 0, highlight: false, ...data }] })
|
||||
this.followPeekJoin(data)
|
||||
bestEffort(window.moho.rpc('subscribe', { bufferId: data.id }), `subscribe ${data.id}`)
|
||||
// A channel this client had not heard of may already hold mentions in the
|
||||
// stored history, so the inbox has to ask again now that the daemon can
|
||||
@@ -3301,6 +3402,7 @@ export class ChatStore {
|
||||
const patch: Partial<ChatState> = {
|
||||
activeBufferId: bufferId,
|
||||
activePanel: '',
|
||||
peek: null,
|
||||
replyingTo: null,
|
||||
buffers: this.state.buffers.map((b) =>
|
||||
b.id === bufferId ? { ...b, unread: 0, highlight: false } : b
|
||||
@@ -3960,7 +4062,58 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
setActivePanel(panel: ActivePanel, joinPanelAccountId = ''): void {
|
||||
this.set({ activePanel: panel, joinPanelAccountId })
|
||||
// A panel is somewhere else to be, and coming back should not find a room
|
||||
// still being looked at that nobody asked to keep.
|
||||
this.set({ activePanel: panel, joinPanelAccountId, ...(panel === '' ? {} : { peek: null }) })
|
||||
}
|
||||
|
||||
/** Look into a room in the conversation pane, without joining it. */
|
||||
startPeek(target: PeekTarget): void {
|
||||
this.set({ peek: target, activePanel: '' })
|
||||
}
|
||||
|
||||
stopPeek(): void {
|
||||
this.set({ peek: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the room being looked at.
|
||||
*
|
||||
* The banner gives way to the box the moment this starts, but the box
|
||||
* belongs to a conversation and the room is not one yet - so the pane holds
|
||||
* the log it was showing until the room arrives from the daemon, and
|
||||
* [`followPeekJoin`] then opens it properly. A join that fails puts the
|
||||
* banner back, because the room is still only being looked at.
|
||||
*/
|
||||
async joinPeeked(): Promise<void> {
|
||||
const peek = this.state.peek
|
||||
if (!peek || peek.joining) return
|
||||
this.set({ peek: { ...peek, joining: true } })
|
||||
// Not for ever: a join the server accepted and then never delivered would
|
||||
// otherwise leave a box that can never be typed into.
|
||||
setTimeout(() => {
|
||||
const now = this.state.peek
|
||||
if (now?.joining && now.roomId === peek.roomId) this.set({ peek: null })
|
||||
}, JOIN_GIVE_UP_MS + 1000)
|
||||
try {
|
||||
await this.joinMatrixRoom(
|
||||
peek.accountId,
|
||||
peek.alias || peek.roomId,
|
||||
peek.name || peek.alias || peek.roomId,
|
||||
peek.alias ? [] : [peek.via]
|
||||
)
|
||||
} catch (e) {
|
||||
const now = this.state.peek
|
||||
if (now?.roomId === peek.roomId) this.set({ peek: { ...now, joining: false } })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** The room that was being joined has turned up: open it for real. */
|
||||
private followPeekJoin(data: { id: string; remoteId?: string }): void {
|
||||
const peek = this.state.peek
|
||||
if (!peek?.joining || !data.remoteId) return
|
||||
if (data.remoteId === peek.roomId || data.remoteId === peek.alias) void this.selectBuffer(data.id)
|
||||
}
|
||||
|
||||
startReply(id: string, from: string, body: string): void {
|
||||
@@ -4058,6 +4211,7 @@ export class ChatStore {
|
||||
const watching = this.state.discordWatching
|
||||
this.set({ discordWatching: null })
|
||||
if (!watching) return
|
||||
closeFeeds(feedStreamKey(watching.accountId, watching.streamKey))
|
||||
await window.moho
|
||||
.rpc('stopWatchingDiscordStream', { accountId: watching.accountId, streamKey: watching.streamKey })
|
||||
.catch(() => {
|
||||
|
||||
+12
-1
@@ -24,7 +24,10 @@ export const IPC = {
|
||||
/** Whether this process got Chromium's sandbox, or is running without it. */
|
||||
sandboxState: 'moho:sandboxState',
|
||||
/** The screens and windows that could be shared into a call. */
|
||||
screenSources: 'moho:screenSources',
|
||||
/** main -> window: show the screen picker (question id, sources). */
|
||||
screenPick: 'moho:screenPick',
|
||||
/** window -> main: the picker's answer (question id, source id or null). */
|
||||
screenPicked: 'moho:screenPicked',
|
||||
markBufferRead: 'moho:markBufferRead',
|
||||
browserLogin: 'moho:browserLogin',
|
||||
solveCaptcha: 'moho:solveCaptcha',
|
||||
@@ -65,3 +68,11 @@ export interface PopoutState {
|
||||
*/
|
||||
watched: string[]
|
||||
}
|
||||
|
||||
/** Something that can be shared into a call, as the picker draws it. */
|
||||
export interface ScreenSource {
|
||||
id: string
|
||||
name: string
|
||||
/** A data: URL of what is on it. */
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user