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:
+1
-1
Submodule nobilis updated: 74b794abc1...02107bd37a
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user