Merge branch 'development'

This commit is contained in:
2026-09-30 16:27:32 -04:00
27 changed files with 2633 additions and 987 deletions
+20 -5
View File
@@ -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
View File
@@ -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.
+130
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+173 -150
View File
@@ -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.
*
+132 -170
View File
@@ -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}&rsquo;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>
)
}
+222
View File
@@ -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>
)
}
-133
View File
@@ -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>
)
}
+73 -14
View File
@@ -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" />
}
+377
View File
@@ -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
}
+171
View File
@@ -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)
}
+45
View File
@@ -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
-148
View File
@@ -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
}
+158
View File
@@ -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
}
+26 -5
View File
@@ -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
}
/**
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}