Show the call: who is in it, and who is speaking

Above the log rather than instead of it. A call and the conversation it
happens in are the same conversation - people paste links and react
while talking - and replacing the messages with faces would make the two
mutually exclusive. Outside the scroller too, since who is talking is
only useful while it can be seen, and a panel that scrolled away with
the backlog would be gone the moment anybody read anything.

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 a picture
of somewhere else.

The ring is an outline rather than a border, so the avatar does not
change size every time somebody starts talking and take the whole row
with it.

Your own tile is rung from the microphone meter, not the speaker list.
You do not receive your own audio, so the one person a call structurally
cannot report as speaking is you - and a view where everyone lights up
except yourself would read as a broken microphone.

Two poll rates: the roster every three seconds, who is talking every
300ms. Arriving and leaving are voice-state dispatches this client does
not otherwise subscribe to, so the roster has to be re-asked or it would
show whoever happened to be there when the call was joined - but a ring
lagging a second behind the voice reads as the wrong person speaking
rather than as a slow indicator.

Submodule moves to the matching daemon.
This commit is contained in:
2026-08-28 17:44:42 -04:00
parent 30b752f3fa
commit 4ad750178f
4 changed files with 212 additions and 1 deletions
+1 -1
Submodule nobilis updated: 74b794abc1...02107bd37a
+58
View File
@@ -915,6 +915,64 @@ 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);
}
/* The bar that appears while picking people for a group message. */
.group-dm-bar {
display: flex;
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { Avatar } from './Avatar'
import { useChat, useStore } from '../state/hooks'
import type { VoiceMember } from '../../../shared/wire'
/** How often to ask who is talking. */
const POLL_MS = 300
interface Levels {
accountId: string
micPeak: number
speakers?: { userId: string; peak: number }[]
}
/**
* The call, while it is happening: who is in it, and who is talking.
*
* 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.
*/
export function CallView({ bufferId }: { bufferId: string }): JSX.Element | null {
const store = useStore()
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 session = sessions.find((s) => s.bufferId === bufferId)
const accountId = session?.accountId
const channelId = session?.channelId
// 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
.rpc<VoiceMember[]>('listVoiceMembers', { accountId, channelId })
.then((rows) => live && setMembers(rows))
.catch(() => undefined)
}
load()
const timer = setInterval(load, 3000)
return () => {
live = false
clearInterval(timer)
}
}, [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.
useEffect(() => {
if (!accountId) return
let live = true
const tick = (): void => {
void window.moho
.rpc<Levels[]>('getVoiceLevels')
.then((rows) => {
if (!live) return
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.
if (mine && mine.micPeak > SPEAKING_FLOOR) next.self = mine.micPeak
setSpeaking(next)
})
.catch(() => undefined)
}
tick()
const timer = setInterval(tick, POLL_MS)
return () => {
live = false
clearInterval(timer)
}
}, [accountId])
if (!session) return null
const buffer = buffers.find((b) => b.id === bufferId)
const talking = (m: VoiceMember): boolean =>
m.isSelf ? speaking.self !== undefined : (speaking[m.userId] ?? 0) > 0
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)}
/>
<IconButton
name="call_end"
title="Hang up"
className="calling"
onClick={() => void store.leaveVoice(session.accountId)}
/>
</div>
{!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'}>
<Avatar name={m.nick} size={56} />
<span className="small ellipsis">
{m.nick}
{m.isSelf && <span className="muted"> (you)</span>}
</span>
</div>
))}
</div>
)}
</div>
)
}
/**
* 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,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Icon } from './Icon'
import { MessageRow, type MessageMode } from './MessageRow'
import { CallView } from './CallView'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
@@ -273,6 +274,10 @@ export function MessageList(): JSX.Element {
return (
<div className="messagelist">
{/* Above the log and outside the scroller. Who is talking is only
useful while it can be seen, and a panel that scrolled away with
the backlog would be gone the moment anybody read anything. */}
<CallView bufferId={bufferId} />
<div className="messagelist-scroll" ref={scrollRef} onScroll={onScroll}>
{/* One wrapper so the whole log has a single measurable height; the
observer above needs an element that grows with the content, which