feat(explore): add card size customization for directory grid
ZhenYi committed
Modified lib/social/src/parse/twitter.rs lib/social/src/parse/twitter.rs +5 −14 View lib/social/src/parse/twitter.rs
@@ -2,10 +2,14 @@
22//!
33//! twitter
44
5use crate::parse::{EmbedRule, Platform};
5use crate::parse::Platform;
66use crate::Category;
77
88/// X(Twitter) — 社交 — twitter.com, x.com, t.co
9///
10/// 只出卡片。官方推文嵌入是 `platform.twitter.com` 的跨域 iframe:在禁掉第三方
11/// 子框架的浏览器(沙箱 WebView、严格跟踪保护)里它停在 `about:blank`,消息流里
12/// 只剩一块 520px 的空白。卡片由聊天侧联网抓取补全作者、正文与封面,到处都能渲染。
913pub struct Twitter;
1014
1115impl Platform for Twitter {
@@ -23,19 +27,6 @@
2327
2428 fn domains(&self) -> &'static [&'static str] {
2529 &["twitter.com", "x.com", "t.co"]
26 }
27
28 fn embed_rule(&self) -> Option<EmbedRule> {
29 // 推文卡片不是 16:9:强制宽高比会把白色卡片塞进一块黑底空框。
30 // 卡片本身没有固定高(带图/带引用的推文更高),取一个能容纳常见推文的高度。
31 Some(
32 EmbedRule::capture(
33 r"^/(?:i/web/status|[^/]+/status(?:es)?)/(\d+)",
34 "https://platform.twitter.com/embed/Tweet.html?id={id}",
35 crate::Aspect::Auto,
36 )
37 .fixed_height(520),
38 )
3930 }
4031}
4132
Modified lib/social/src/render.rs lib/social/src/render.rs +9 −2 View lib/social/src/render.rs
@@ -169,10 +169,11 @@
169169 /// 卡片类嵌入不该带黑底,视频类才带;声明了固定高度就用它。
170170 #[test]
171171 fn frame_style_matches_the_embed_shape() {
172 // X 只出卡片:跨域 iframe 会停在 about:blank,不能再留 520px 的空框。
172173 let tweet = crate::parse("https://x.com/u/status/1234567890").expect("tweet resolves");
173174 let html = render_html(&tweet);
174 assert!(html.contains("height:520px"), "{html}");
175 assert!(!html.contains("background:#000"), "卡片不该有黑底: {html}");
175 assert!(html.contains("gd-embed-link"), "{html}");
176 assert!(!html.contains("<iframe"), "{html}");
176177
177178 let video = crate::parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ").expect("video");
178179 let html = render_html(&video);
@@ -185,5 +186,11 @@
185186 let song = crate::parse("https://music.163.com/#/song?id=123").expect("song");
186187 let html = render_html(&song);
187188 assert!(html.contains("height:66px"), "{html}");
189
190 // 固定高度仍然生效(Reddit oEmbed 声明 500px)。
191 let thread =
192 crate::parse("https://www.reddit.com/r/rust/comments/abc/title").expect("reddit");
193 let html = render_html(&thread);
194 assert!(html.contains("height:500px"), "{html}");
188195 }
189196}
Modified web/src/app/styles/workspace.css web/src/app/styles/workspace.css +8 −0 View web/src/app/styles/workspace.css
@@ -52,6 +52,14 @@
5252.workspace-main:focus { outline: none; }
5353.workspace-icon-button { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 10px; color: var(--shell-ink-muted); transition: background 160ms, color 160ms; }
5454.workspace-icon-button:hover { background: var(--shell-active); color: var(--shell-ink); }
55/* Three footer controls instead of two: the row tightens to keep the name legible. */
56.workspace-icon-button.is-compact { width: 32px; height: 32px; border-radius: 9px; }
57/* A toggle that is "off" (muted microphone, deafened headphones) reads red. */
58.workspace-icon-button.is-off { color: var(--error-foreground); }
59.workspace-icon-button.is-off:hover {
60 background: color-mix(in srgb, var(--destructive) 12%, transparent);
61 color: var(--error-foreground);
62}
5563.workspace-collapse { width: 28px; height: 28px; }
5664.workspace-expand { position: absolute; z-index: 15; top: 20px; left: 14px; background: var(--shell-field); opacity: 0; visibility: hidden; transform: translateX(-8px) scale(.94); transition: opacity 120ms ease, transform 180ms ease, background 160ms, color 160ms, visibility 0s linear 120ms; }
5765.navigation-collapsed .workspace-expand { opacity: 1; visibility: visible; transform: none; transition: opacity 180ms ease calc(var(--nav-motion) * .5), transform 300ms var(--nav-ease) calc(var(--nav-motion) * .45), background 160ms, color 160ms, visibility 0s; }
Modified web/src/entities/chat/model/meta.ts web/src/entities/chat/model/meta.ts +15 −0 View web/src/entities/chat/model/meta.ts
@@ -133,6 +133,20 @@
133133}
134134
135135/**
136 * Embeds are replaced wholesale by the server's async unfurl pass, so rows whose
137 * cards changed must not compare equal: a cached row would otherwise keep
138 * rendering the stale card (or a broken iframe) after a reload.
139 */
140function embedsKey(embeds: ChatMessage['embeds']): string {
141 if (!embeds || embeds.length === 0) return ''
142 return embeds
143 .map(embed =>
144 [embed.kind, embed.src, embed.url, embed.title, embed.image_url, embed.description].join('\u0001'),
145 )
146 .join('\u0002')
147}
148
149/**
136150 * Field-level equality for the parts a row renders. The socket re-delivers rows
137151 * (a `history` snapshot repeats what the channel already delivered), and treating
138152 * an identical copy as a change would re-render the whole transcript on every
@@ -141,6 +155,7 @@
141155export function sameRow(a: ChatMessage, b: ChatMessage): boolean {
142156 if (a === b) return true
143157 if (a.content !== b.content) return false
158 if (embedsKey(a.embeds) !== embedsKey(b.embeds)) return false
144159 if (a.edited_at !== b.edited_at || a.deleted_at !== b.deleted_at) return false
145160 if (a.pinned !== b.pinned || a.pinned_at !== b.pinned_at) return false
146161 if ((a.thread_id ?? null) !== (b.thread_id ?? null)) return false
Modified web/src/features/chat/ui/voice/voice-call-bar.tsx web/src/features/chat/ui/voice/voice-call-bar.tsx +13 −2 View web/src/features/chat/ui/voice/voice-call-bar.tsx
@@ -7,7 +7,7 @@
77 * want the bottom of the screen, so the bar stays where the user parked it.
88 */
99import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
10import { Link } from 'react-router-dom'
10import { Link, useLocation } from 'react-router-dom'
1111import {
1212 AlertCircle,
1313 GripVertical,
@@ -59,9 +59,16 @@
5959 return target instanceof Element && target.closest('button, a') !== null
6060}
6161
62/** The voice room the current route shows, if it is one. */
63function roomIdInPath(pathname: string): string | null {
64 const match = /^\/app\/[^/]+\/chat\/([^/]+)/.exec(pathname)
65 return match ? decodeURIComponent(match[1]) : null
66}
67
6268export function VoiceCallBar() {
6369 const { t } = useT()
6470 const session = useVoiceSession()
71 const location = useLocation()
6572 const barRef = useRef<HTMLDivElement | null>(null)
6673 const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null)
6774 const [position, setPosition] = useState<Point | null>(readStoredPosition)
@@ -125,7 +132,11 @@
125132 }
126133
127134 const active = session.roomId !== null && session.status !== 'idle'
128 if (!active) return null
135 // Inside the room the call belongs to, the room's own control bar carries the
136 // same actions - and its right-hand corner belongs to the settings menu, so a
137 // floating pill there would only overlap it.
138 const inOwnRoom = session.roomId !== null && roomIdInPath(location.pathname) === session.roomId
139 if (!active || inOwnRoom) return null
129140
130141 // The label and the route are captured at join time; a call keeps working
131142 // from any page, so the bar never depends on the current route params.
Modified web/src/features/chat/ui/voice/voice-device-sheet.tsx web/src/features/chat/ui/voice/voice-device-sheet.tsx +4 −1 View web/src/features/chat/ui/voice/voice-device-sheet.tsx
@@ -36,6 +36,8 @@
3636 busy: boolean
3737 /** Stage channels cannot open the mic on join, so no capture is requested. */
3838 allowMicrophone: boolean
39 /** What the "join with microphone" checkbox starts from (the footer toggle). */
40 defaultMicOn?: boolean
3941 error: string | null
4042 onCancel: () => void
4143 onConfirm: (choice: VoiceJoinChoice) => void
@@ -97,6 +99,7 @@
9799 open,
98100 busy,
99101 allowMicrophone,
102 defaultMicOn = true,
100103 error,
101104 onCancel,
102105 onConfirm,
@@ -111,7 +114,7 @@
111114 normalize(userChoices.videoDeviceId),
112115 )
113116 const [outputDeviceId, setOutputDeviceId] = useState<string | null>(readOutputDevice)
114 const [micOn, setMicOn] = useState(allowMicrophone)
117 const [micOn, setMicOn] = useState(allowMicrophone && defaultMicOn)
115118 const [previewError, setPreviewError] = useState<string | null>(null)
116119 const videoRef = useRef<HTMLVideoElement | null>(null)
117120
Modified web/src/features/chat/ui/voice/voice-room-panel.tsx web/src/features/chat/ui/voice/voice-room-panel.tsx +5 −2 View web/src/features/chat/ui/voice/voice-room-panel.tsx
@@ -241,12 +241,14 @@
241241 return
242242 }
243243 await start({
244 startWithMic: true,
244 // The footer's microphone toggle is the remembered default; the sheet is
245 // where an explicit per-join choice is made.
246 startWithMic: !session.micMuted,
245247 audioDeviceId: asRemembered(userChoices.audioDeviceId),
246248 videoDeviceId: asRemembered(userChoices.videoDeviceId),
247249 audioOutputDeviceId: readOutputDevice(),
248250 })
249 }, [isStage, start, userChoices.audioDeviceId, userChoices.videoDeviceId])
251 }, [isStage, session.micMuted, start, userChoices.audioDeviceId, userChoices.videoDeviceId])
250252 const onRaiseHand = useCallback(async () => {
251253 setError(null)
252254 try {
@@ -576,6 +578,7 @@
576578 open={sheetOpen}
577579 busy={joining}
578580 allowMicrophone={!isStage}
581 defaultMicOn={!session.micMuted}
579582 error={error}
580583 onCancel={() => setSheetOpen(false)}
581584 onConfirm={choice => void start(choice)}
Modified web/src/features/chat/ui/voice/voice-tile-layout.ts web/src/features/chat/ui/voice/voice-tile-layout.ts +146 −20 View web/src/features/chat/ui/voice/voice-tile-layout.ts
@@ -64,6 +64,9 @@
6464 origin: TileBox
6565 startX: number
6666 startY: number
67 /** The box under the pointer, painted the moment it changes. */
68 target: TileBox
69 el: HTMLElement
6770}
6871
6972interface CanvasBox {
@@ -92,14 +95,36 @@
9295 * Cadence for drawing *somebody else's* move. A drag reaches this tab as a burst
9396 * of snapshots ~140ms apart, one per server write; applying each one would step
9497 * the window across the canvas. Instead the newest snapshot is held and applied
95 * on this cadence, and the window interpolates across the gap (see the glide
96 * transition on `.voice-window.is-floating`) — the move then trails the person
97 * dragging it by about this much and travels continuously rather than in jumps.
98 * on this cadence, and the window is tweened across the gap (see
99 * [`REMOTE_GLIDE_MS`]) — the move then trails the person dragging it by about
100 * this much and travels continuously rather than in jumps.
98101 */
99102const REMOTE_APPLY_MS = 500
103/**
104 * Length of one remote glide. Interpolation lives here, in JavaScript, because
105 * the browser this runs in reports `prefers-reduced-motion: reduce` — which kills
106 * a CSS transition and left other people's windows hopping from sample to sample.
107 * Matching the apply cadence makes one update flow into the next.
108 */
109const REMOTE_GLIDE_MS = 460
100110
101111const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0)
102112
113/**
114 * Paint one pixel box on the window element. The local gesture writes here
115 * instead of going through React state: the board is not re-rendered per pointer
116 * event, and the window stays glued to the cursor. Interpolation is reserved for
117 * *remote* moves (see REMOTE_APPLY_MS and the glide transition on
118 * `.voice-window.is-floating`), where the missing frames are the ones this tab
119 * never sees.
120 */
121function paintBox(el: HTMLElement, box: TileBox): void {
122 el.style.left = box.left + 'px'
123 el.style.top = box.top + 'px'
124 el.style.width = box.width + 'px'
125 el.style.height = box.height + 'px'
126}
127
103128
104129/**
105130 * Per-axis spans of the canvas: the pixels a window may occupy on each axis, and
@@ -246,6 +271,8 @@
246271 pending: {},
247272 timer: 0,
248273 })
274 /** In-flight glides, one per window: the next sample for a key supersedes it. */
275 const tweensRef = useRef(new Map<string, { raf: number; start: number }>())
249276 /** The room's layout has been read once: the first pull is hydration, not a move. */
250277 const hydratedRef = useRef(false)
251278
@@ -358,6 +385,60 @@
358385 )
359386
360387 /**
388 * Glide one window from the box currently painted on its element to the box an
389 * adopted placement renders at. Only the gesture and this tween ever write
390 * pixels; React is handed the placement once the motion has landed, which is
391 * what keeps its style write from restarting the movement.
392 */
393 const glideTile = useCallback(
394 (canvas: HTMLDivElement, key: string, placement: PlacedTile) => {
395 const el = canvas.querySelector<HTMLElement>('[data-tile="' + key + '"]')
396 const running = tweensRef.current.get(key)
397 if (running) {
398 window.cancelAnimationFrame(running.raf)
399 tweensRef.current.delete(key)
400 }
401 const to = pixelsFor(canvasSize, placement)
402 // A canvas that is not measured yet, or a window still in the automatic
403 // grid, has no absolute box to glide from: land it directly.
404 if (!to || !el || !el.classList.contains('is-floating')) {
405 commit({ ...layoutRef.current, [key]: placement })
406 return
407 }
408 const canvasRect = canvas.getBoundingClientRect()
409 const rect = el.getBoundingClientRect()
410 const from: TileBox = {
411 left: rect.left - canvasRect.left + canvas.scrollLeft,
412 top: rect.top - canvasRect.top + canvas.scrollTop,
413 width: rect.width,
414 height: rect.height,
415 }
416 const tween = { raf: 0, start: 0 }
417 const frame = (now: number) => {
418 if (!tween.start) tween.start = now
419 const t = Math.min(1, (now - tween.start) / REMOTE_GLIDE_MS)
420 const ease = 1 - (1 - t) ** 3
421 paintBox(el, {
422 left: from.left + (to.left - from.left) * ease,
423 top: from.top + (to.top - from.top) * ease,
424 width: from.width + (to.width - from.width) * ease,
425 height: from.height + (to.height - from.height) * ease,
426 })
427 if (t < 1) {
428 tween.raf = window.requestAnimationFrame(frame)
429 return
430 }
431 tweensRef.current.delete(key)
432 paintBox(el, to)
433 if (el.isConnected) commit({ ...layoutRef.current, [key]: placement })
434 }
435 tween.raf = window.requestAnimationFrame(frame)
436 tweensRef.current.set(key, tween)
437 },
438 [canvasSize, commit],
439 )
440
441 /**
361442 * Draw whatever the last slot collected. Reading the pending map *inside* the
362443 * timer is what makes the newest snapshot win: a burst of pushes collapses
363444 * into one application, so the window lands on a position its owner really
@@ -385,8 +466,14 @@
385466 ]),
386467 ),
387468 )
388 if (adopted) commit({ ...layoutRef.current, ...adopted })
389 }, [commit, placementsFrom])
469 if (!adopted) return
470 const canvas = canvasEl
471 if (!canvas) {
472 commit({ ...layoutRef.current, ...adopted })
473 return
474 }
475 for (const [key, placement] of Object.entries(adopted)) glideTile(canvas, key, placement)
476 }, [canvasEl, commit, glideTile, placementsFrom])
390477
391478 const pullRemote = useCallback(async () => {
392479 let tiles: VoiceLayoutTiles
@@ -436,7 +523,15 @@
436523 height: tileRect.height,
437524 }
438525 const parked = layoutRef.current[key]
439 const origin = (parked ? pixelsFor(canvasSize, parked) : null) ?? rectBox
526 // Grabbing a window in the middle of a remote glide: stop the glide and
527 // lift from what is painted, or the first pointer-down would snap it to a
528 // target its owner already moved past.
529 const gliding = tweensRef.current.get(key)
530 if (gliding) {
531 window.cancelAnimationFrame(gliding.raf)
532 tweensRef.current.delete(key)
533 }
534 const origin = gliding || !parked ? rectBox : (pixelsFor(canvasSize, parked) ?? rectBox)
440535 movedKeys.current.delete(key)
441536 gestureRef.current = {
442537 mode,
@@ -444,13 +539,18 @@
444539 origin,
445540 startX: event.clientX,
446541 startY: event.clientY,
542 target: origin,
543 el,
447544 }
448545 draggingRef.current = key
449546 setDraggingKey(key)
450 // A grid window lifts out of the flow at exactly where it was drawn, so
451 // the first pointer-down never makes it jump.
452 if (!parked) {
453 commit({ ...layoutRef.current, [key]: { ...normalizedFor(canvasSize, rectBox), updatedAt: 0 } })
547 // A grid window (or one caught mid-glide) lifts out of the flow at exactly
548 // where it was drawn, so the first pointer-down never makes it jump.
549 if (!parked || gliding) {
550 commit({
551 ...layoutRef.current,
552 [key]: { ...normalizedFor(canvasSize, rectBox), updatedAt: parked?.updatedAt ?? 0 },
553 })
454554 }
455555 el.setPointerCapture(event.pointerId)
456556 },
@@ -464,30 +564,37 @@
464564 const dx = event.clientX - active.startX
465565 const dy = event.clientY - active.startY
466566 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) movedKeys.current.add(key)
467 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
468567 // Both axes follow their own delta: the handle is a free corner resize, not
469568 // a proportional scale, so dragging straight down makes the window taller
470 // without touching its width.
569 // without touching its width. Nothing is snapped or smoothed here: the box
570 // under the pointer is what gets painted, so the window tracks the cursor
571 // exactly. Quantizing happens once, on release.
471572 const next: TileBox =
472573 active.mode === 'move'
473574 ? {
474 left: snap(active.origin.left + dx),
475 top: snap(active.origin.top + dy),
575 left: active.origin.left + dx,
576 top: active.origin.top + dy,
476577 width: active.origin.width,
477578 height: active.origin.height,
478579 }
479580 : {
480581 ...active.origin,
481 width: snap(active.origin.width + dx),
482 height: snap(active.origin.height + dy),
582 width: active.origin.width + dx,
583 height: active.origin.height + dy,
483584 }
484 commit({
585 active.target = next
586 paintBox(active.el, next)
587 // Ref-only write: the throttled pushes and the final commit read this.
588 // Re-rendering the board per pointer event is what used to make the drag
589 // choppy, and a re-render is the only thing interpolation could smooth —
590 // the local window needs neither.
591 layoutRef.current = {
485592 ...layoutRef.current,
486593 [key]: { ...normalizedFor(canvasSize, next), updatedAt: 0 },
487 })
594 }
488595 pushKey(key, false)
489596 },
490 [canvasSize, commit, pushKey],
597 [canvasSize, pushKey],
491598 )
492599
493600 const endGesture = useCallback(
@@ -497,9 +604,25 @@
497604 gestureRef.current = null
498605 draggingRef.current = null
499606 setDraggingKey(null)
607 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
608 const settled: TileBox = {
609 left: snap(active.target.left),
610 top: snap(active.target.top),
611 width: snap(active.target.width),
612 height: snap(active.target.height),
613 }
614 // Paint the snapped box before React takes the style back: the commit below
615 // re-renders from state, and a raw value there would twitch the window at
616 // the very end of the drag.
617 paintBox(active.el, settled)
618 const parked = layoutRef.current[key]
619 commit({
620 ...layoutRef.current,
621 [key]: { ...normalizedFor(canvasSize, settled), updatedAt: parked?.updatedAt ?? 0 },
622 })
500623 pushKey(key, true)
501624 },
502 [pushKey],
625 [canvasSize, commit, pushKey],
503626 )
504627
505628 /** One-shot: true the first time it is asked after that window was dragged. */
@@ -533,6 +656,7 @@
533656 useEffect(() => {
534657 const push = pushRef.current
535658 const remote = remoteRef.current
659 const tweens = tweensRef.current
536660 return () => {
537661 if (push.timer) clearTimeout(push.timer)
538662 push.timer = 0
@@ -540,6 +664,8 @@
540664 if (remote.timer) clearTimeout(remote.timer)
541665 remote.timer = 0
542666 remote.pending = {}
667 for (const tween of tweens.values()) window.cancelAnimationFrame(tween.raf)
668 tweens.clear()
543669 }
544670 }, [])
545671
Modified web/src/features/chat/ui/voice/voice-tiles.tsx web/src/features/chat/ui/voice/voice-tiles.tsx +3 −15 View web/src/features/chat/ui/voice/voice-tiles.tsx
@@ -37,11 +37,10 @@
3737 type TrackReferenceOrPlaceholder,
3838} from '@/shared/livekit'
3939import { useT } from '@/shared/i18n'
40import { rememberedVolume, rememberVolume } from '@/shared/voice'
4041import { initials } from './voice-format'
4142import { VoiceAvatarTile } from './voice-avatar-tile'
4243import type { VoiceTileLayout } from './voice-tile-layout'
43
44const VOLUME_PREFIX = 'gitdata.voice.volume.'
4544
4645interface BoardProps {
4746 roomId: string
@@ -99,14 +98,7 @@
9998 const pinned = usePinnedTracks(layoutContext)
10099 const isPinned = pinned.some(entry => entry.participant.identity === identity)
101100 const isSelf = identity === viewerId
102 const remembered = useMemo(() => {
103 try {
104 const raw = Number(localStorage.getItem(VOLUME_PREFIX + roomId + '.' + identity))
105 return Number.isFinite(raw) && raw > 0 ? raw : 1
106 } catch {
107 return 1
108 }
109 }, [roomId, identity])
101 const remembered = useMemo(() => rememberedVolume(roomId, identity), [roomId, identity])
110102 const [volume, setVolume] = useState(remembered)
111103
112104 const applyVolume = (next: number) => {
@@ -115,11 +107,7 @@
115107 if (participant instanceof Object && 'setVolume' in participant) {
116108 ;(participant as RemoteParticipant).setVolume(next)
117109 }
118 try {
119 localStorage.setItem(VOLUME_PREFIX + roomId + '.' + identity, String(next))
120 } catch {
121 // Volume memory is a convenience, never a requirement.
122 }
110 rememberVolume(roomId, identity, next)
123111 }
124112
125113 const name = displayNameOf(trackRef)
Modified web/src/features/chat/ui/voice/voice.css web/src/features/chat/ui/voice/voice.css +9 −13 View web/src/features/chat/ui/voice/voice.css
@@ -432,10 +432,7 @@
432432 left: 12px;
433433 animation: none;
434434 }
435 /* 逐格跳动的窗口对前庭敏感的人更难读,直接给到最终位置。 */
436 .voice-window.is-floating:not(.is-dragging) {
437 transition: none;
438 }
435
439436}
440437
441438/* ---------------------------------------------- LiveKit 组件主题对齐 shell --
@@ -559,14 +556,10 @@
559556}
560557.voice-window.is-dragging { z-index: 3; cursor: grabbing; }
561558/*
562 * 别人拖动时,窗口是按 REMOTE_APPLY_MS 的节拍跳格子的;补上这段过渡,两次应用
563 * 之间就是滑动过去的,看起来才是「移动」而不是「瞬移」。拖动中的窗口和摆位以外
564 * 的窗口不能有过渡,否则松手的落点会慢半拍、网格重排也会被拖泥带水。
559 * 别人的移动由 JS 逐帧补间(voice-tile-layout 的 REMOTE_GLIDE_MS):这里刻意
560 * 不留 CSS 过渡,一是避免两层动画叠在一起,二是本机 prefers-reduced-motion 会
561 * 把过渡时长清零,补间必须走 JS 才在哪儿都生效。自己的拖拽则完全不过渡。
565562 */
566.voice-window.is-floating:not(.is-dragging) {
567 transition: left 460ms cubic-bezier(.22,.68,.28,1), top 460ms cubic-bezier(.22,.68,.28,1),
568 width 460ms cubic-bezier(.22,.68,.28,1), height 460ms cubic-bezier(.22,.68,.28,1);
569}
570563/* 摄像头窗口上的麦克风状态:官方指示器只标注它渲染的那一路。 */
571564.voice-call-quality {
572565 width: 14px;
@@ -635,11 +628,14 @@
635628.voice-tile-volume { display: flex; align-items: center; gap: 4px; color: var(--shell-ink-muted); }
636629.voice-tile-volume input[type='range'] { width: 68px; accent-color: var(--primary); cursor: pointer; }
637630
638/* 会中设备弹层。 */
631/*
632 * 会中设备弹层。右下角那一列从 70px 起、高 52px(.voice-controls 的圆钮),
633 * 旧的 78px 让弹层正好压在圆钮上;留到 132px 让整块菜单停在圆钮上方。
634 */
639635.voice-settings-anchor {
640636 position: absolute;
641637 right: 16px;
642 bottom: 78px;
638 bottom: 132px;
643639 z-index: 5;
644640}
645641.voice-settings {
Deleted web/src/features/notifications/ui/inbox-button.tsx web/src/features/notifications/ui/inbox-button.tsx +0 −30
@@ -1,30 +0,0 @@
1import { Link } from 'react-router-dom'
2import { Bell } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { useUnreadNotificationCount } from '../model/use-unread-count'
5import { NOTIFY_PATH } from './notify-nav-item'
6
7/**
8 * Side-footer entry to the personal inbox: bell + live unread badge, visible
9 * in every space. The @me sidebar's `NotifyNavItem` is the pinned row above
10 * the workspace list; both share the same live count.
11 */
12export function NotifyInboxButton() {
13 const { t } = useT()
14 const { count, capped } = useUnreadNotificationCount()
15 return (
16 <Link
17 to={NOTIFY_PATH}
18 aria-label={count > 0 ? t('notifications.bellUnread', { count }) : t('notifications.bell')}
19 title={t('notifications.bell')}
20 className="workspace-icon-button relative"
21 >
22 <Bell className="h-4 w-4" />
23 {count > 0 ? (
24 <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-shell-action px-1 text-[10px] font-semibold leading-none text-white">
25 {capped ? '50+' : count > 99 ? '99+' : count}
26 </span>
27 ) : null}
28 </Link>
29 )
30}
Modified web/src/features/notifications/index.ts web/src/features/notifications/index.ts +0 −1 View web/src/features/notifications/index.ts
@@ -4,6 +4,5 @@
44// Consumers import from '@/features/notifications', never deeper files.
55export { NotifyView } from './ui/notify-view'
66export { NotifyToaster } from './ui/notify-toaster'
7export { NotifyInboxButton } from './ui/inbox-button'
87export { NotifyNavItem, NOTIFY_PATH } from './ui/notify-nav-item'
98export type { NotificationRow } from './model/api'
Modified web/src/features/space-discovery/ui/explore-view.tsx web/src/features/space-discovery/ui/explore-view.tsx +128 −17 View web/src/features/space-discovery/ui/explore-view.tsx
@@ -51,6 +51,79 @@
5151
5252const PAGE_SIZE = 24
5353
54/** Card density for the directory grid: three steps, remembered per browser. */
55type CardSize = 'lg' | 'md' | 'sm'
56
57const CARD_SIZE_KEY = 'gitdata.explore.cardSize'
58
59interface CardLayout {
60 /** Grid columns per size; literal strings so the CSS build keeps every class. */
61 grid: string
62 banner: string
63 avatar: string
64 avatarAnchor: string
65 body: string
66 name: string
67 meta: string
68 desc: string
69 actionButton: string
70 actionText: string
71}
72
73const CARD_LAYOUTS: Record<CardSize, CardLayout> = {
74 lg: {
75 grid: 'grid-cols-1 gap-5 lg:grid-cols-2',
76 banner: 'aspect-[16/6]',
77 avatar: 'h-14 w-14 text-[18px]',
78 avatarAnchor: '-bottom-6 left-5',
79 body: 'px-5 pb-5 pt-8',
80 name: 'text-[16px]',
81 meta: 'text-[12px]',
82 desc: 'mt-1 line-clamp-3 min-h-[3.9em] text-[13px] leading-[1.3] text-shell-ink-muted',
83 actionButton: 'h-9 text-[13px]',
84 actionText: 'text-[13px]',
85 },
86 md: {
87 grid: 'grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3',
88 banner: 'aspect-[16/7]',
89 avatar: 'h-12 w-12 text-[16px]',
90 avatarAnchor: '-bottom-5 left-4',
91 body: 'px-4 pb-4 pt-7',
92 name: 'text-[14.5px]',
93 meta: 'text-[11.5px]',
94 desc: 'mt-1 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-shell-ink-muted',
95 actionButton: 'h-8 text-[12.5px]',
96 actionText: 'text-[12.5px]',
97 },
98 sm: {
99 grid: 'grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
100 banner: 'aspect-[16/5]',
101 avatar: 'h-9 w-9 text-[12.5px]',
102 avatarAnchor: '-bottom-4 left-3',
103 body: 'px-3 pb-3 pt-6',
104 name: 'text-[13px]',
105 meta: 'text-[10.5px]',
106 desc: 'mt-1 line-clamp-1 min-h-[1.3em] text-[11.5px] leading-[1.3] text-shell-ink-muted',
107 actionButton: 'h-7 text-[11.5px]',
108 actionText: 'text-[11.5px]',
109 },
110}
111
112const CARD_SIZE_KEYS: Record<CardSize, MessageKey> = {
113 lg: 'space.discover.cardSize.large',
114 md: 'space.discover.cardSize.medium',
115 sm: 'space.discover.cardSize.small',
116}
117
118function readCardSize(): CardSize {
119 try {
120 const raw = localStorage.getItem(CARD_SIZE_KEY)
121 if (raw === 'lg' || raw === 'md' || raw === 'sm') return raw
122 } catch {
123 // Storage may be unavailable; the default size still works.
124 }
125 return 'md'
126}
54127export interface ExploreViewProps {
55128 /** Opens the create-space modal owned by the shell. */
56129 onAddSpace: () => void
@@ -68,6 +141,7 @@
68141 const [query, setQuery] = useState('')
69142 const [debounced, setDebounced] = useState('')
70143 const [sort, setSort] = useState<'members' | 'newest'>('members')
144 const [cardSize, setCardSize] = useState<CardSize>(readCardSize)
71145 const [limit, setLimit] = useState(PAGE_SIZE)
72146 const [reload, setReload] = useState(0)
73147 const [busyId, setBusyId] = useState<string | null>(null)
@@ -92,6 +166,16 @@
92166 )
93167 const rows = page.data?.items ?? []
94168 const total = page.data?.total ?? 0
169 const layout = CARD_LAYOUTS[cardSize]
170
171 const changeCardSize = (next: CardSize) => {
172 setCardSize(next)
173 try {
174 localStorage.setItem(CARD_SIZE_KEY, next)
175 } catch {
176 // The preference is a convenience; failing to store it must not throw.
177 }
178 }
95179
96180 const openSpace = (space: ExploreSpace) => {
97181 navigate(`/app/${encodeURIComponent(space.name)}`)
@@ -178,9 +262,31 @@
178262 <span className="text-[12.5px] text-shell-ink-muted">
179263 {t('space.discover.resultCount', { count: total })}
180264 </span>
181 <Button variant="secondary" className="ml-auto h-9 text-[12.5px]" onClick={onAddSpace}>
182 {t('shell.rail.addSpace')}
183 </Button>
265 <div className="ml-auto flex items-center gap-2">
266 <div
267 className="flex items-center gap-1 rounded-field bg-shell-field p-1"
268 role="group"
269 aria-label={t('space.discover.cardSize')}
270 >
271 {(['lg', 'md', 'sm'] as const).map(value => (
272 <button
273 key={value}
274 type="button"
275 aria-pressed={cardSize === value}
276 title={t(CARD_SIZE_KEYS[value])}
277 onClick={() => changeCardSize(value)}
278 className={`rounded-[8px] px-2.5 py-1.5 text-[12.5px] transition-colors ${
279 cardSize === value ? 'bg-shell-surface font-semibold shadow-sm' : 'text-shell-ink-muted hover:text-shell-ink'
280 }`}
281 >
282 {t(CARD_SIZE_KEYS[value])}
283 </button>
284 ))}
285 </div>
286 <Button variant="secondary" className="h-9 text-[12.5px]" onClick={onAddSpace}>
287 {t('shell.rail.addSpace')}
288 </Button>
289 </div>
184290 </div>
185291
186292 {actionError ? (
@@ -209,11 +315,12 @@
209315 </div>
210316 ) : (
211317 <>
212 <ul className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
318 <ul className={`grid ${layout.grid}`}>
213319 {rows.map(space => (
214320 <SpaceCard
215321 key={space.id}
216322 space={space}
323 layout={layout}
217324 busy={busyId === space.id}
218325 onOpen={() => openSpace(space)}
219326 onApply={() => {
@@ -284,12 +391,14 @@
284391
285392function SpaceCard({
286393 space,
394 layout,
287395 busy,
288396 onOpen,
289397 onApply,
290398 onWithdraw,
291399}: {
292400 space: ExploreSpace
401 layout: CardLayout
293402 busy: boolean
294403 onOpen: () => void
295404 onApply: () => void
@@ -311,35 +420,35 @@
311420 const banner = !bannerFailed ? space.banner_url : null
312421
313422 const action = isMember ? (
314 <Button className="h-8 flex-1 text-[12.5px]" onClick={onOpen}>
423 <Button className={`flex-1 ${layout.actionButton}`} onClick={onOpen}>
315424 {t('space.discover.enter')}
316425 </Button>
317426 ) : request?.status === 'pending' ? (
318427 <>
319 <span className="flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-[12.5px] text-shell-ink-muted">
428 <span className={`flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-shell-ink-muted ${layout.actionText}`}>
320429 {t('space.discover.pending')}
321430 </span>
322 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onWithdraw}>
431 <Button variant="secondary" className={layout.actionButton} disabled={busy} onClick={onWithdraw}>
323432 {t('space.discover.withdraw')}
324433 </Button>
325434 </>
326435 ) : cooldownUntil ? (
327 <span className="flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-[12.5px] text-shell-ink-muted">
436 <span className={`flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-shell-ink-muted ${layout.actionText}`}>
328437 {t('space.discover.cooldownUntil', { time: fmtDate(new Date(cooldownUntil).toISOString()) })}
329438 </span>
330439 ) : !space.allow_join ? (
331 <span className="flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-[12.5px] text-shell-ink-muted">
440 <span className={`flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-shell-ink-muted ${layout.actionText}`}>
332441 {t('space.discover.closed')}
333442 </span>
334443 ) : (
335 <Button className="h-8 flex-1 text-[12.5px]" onClick={onApply}>
444 <Button className={`flex-1 ${layout.actionButton}`} onClick={onApply}>
336445 {t('space.discover.apply')}
337446 </Button>
338447 )
339448
340449 return (
341450 <li className="flex flex-col overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
342 <div className="relative aspect-[16/7] w-full" style={banner ? undefined : { background: bannerGradient(space.id) }}>
451 <div className={`relative w-full ${layout.banner}`} style={banner ? undefined : { background: bannerGradient(space.id) }}>
343452 {banner ? (
344453 <img
345454 src={banner}
@@ -349,7 +458,9 @@
349458 className="h-full w-full object-cover"
350459 />
351460 ) : null}
352 <span className="absolute -bottom-5 left-4 flex h-12 w-12 items-center justify-center overflow-hidden rounded-full border-2 border-shell-surface bg-shell-tile text-[16px] font-semibold text-shell-ink">
461 <span
462 className={`absolute flex items-center justify-center overflow-hidden rounded-full border-2 border-shell-surface bg-shell-tile font-semibold text-shell-ink ${layout.avatarAnchor} ${layout.avatar}`}
463 >
353464 {space.avatar_url ? (
354465 <img src={space.avatar_url} alt="" className="h-full w-full object-cover" />
355466 ) : (
@@ -357,18 +468,18 @@
357468 )}
358469 </span>
359470 </div>
360 <div className="flex min-h-0 flex-1 flex-col px-4 pb-4 pt-7">
471 <div className={`flex min-h-0 flex-1 flex-col ${layout.body}`}>
361472 <div className="flex items-start justify-between gap-2">
362 <h3 className="truncate text-[14.5px] font-semibold">{space.name}</h3>
363 <span className="flex shrink-0 items-center gap-1 text-[11.5px] text-shell-ink-muted">
473 <h3 className={`truncate font-semibold ${layout.name}`}>{space.name}</h3>
474 <span className={`flex shrink-0 items-center gap-1 text-shell-ink-muted ${layout.meta}`}>
364475 <Users className="h-3.5 w-3.5" aria-hidden />
365476 {t('space.discover.members', { count: space.members_count })}
366477 </span>
367478 </div>
368 <p className="mt-1 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-shell-ink-muted">
479 <p className={layout.desc}>
369480 {space.description || ''}
370481 </p>
371 <p className="mt-2 text-[11.5px] text-shell-ink-muted">
482 <p className={`mt-2 text-shell-ink-muted ${layout.meta}`}>
372483 {t('space.discover.joinLevel', { level: t(JOIN_LEVEL_KEYS[space.allow_join_level]) })}
373484 </p>
374485 <div className="mt-3 flex items-center gap-2">{action}</div>
Modified web/src/pages/workspace/ui/user-panel.tsx web/src/pages/workspace/ui/user-panel.tsx +48 −6 View web/src/pages/workspace/ui/user-panel.tsx
@@ -1,12 +1,24 @@
1import { Settings } from 'lucide-react'
1import { HeadphoneOff, Headphones, Mic, MicOff, Settings } from 'lucide-react'
22import { useT } from '@/shared/i18n'
3import { NotifyInboxButton } from '@/features/notifications'
3import { useVoiceSession, voiceSession } from '@/shared/voice'
44import { initial } from '../model/initial'
55import type { Me } from '../model/workspace'
66
7/** AppShell `sideFooter` slot: signed-in identity plus the local device row. */
7/** AppShell `sideFooter` slot: signed-in identity plus the global voice toggles. */
88export function UserPanel({ me, onSettings }: { me: Me; onSettings?: () => void }) {
99 const { t } = useT()
10 const session = useVoiceSession()
11 const inCall = session.roomId !== null && session.status !== 'idle'
12 // In a call the two toggles drive the live tracks; outside one the microphone
13 // button holds the state the next join starts from and the deafen flag waits
14 // for the connect.
15 const micOn = inCall ? session.micEnabled : !session.micMuted
16 const micTitle =
17 inCall && !session.canPublish
18 ? t('chat.voice.noPublish')
19 : micOn
20 ? t('shell.user.mute')
21 : t('shell.user.unmute')
1022 return (
1123 <>
1224 {me.user.avatar_url ? (
@@ -28,14 +40,44 @@
2840 @{me.user.username}
2941 </div>
3042 </div>
31 <div className="flex shrink-0 items-center gap-2 text-shell-ink-muted">
32 <NotifyInboxButton />
43 <div className="flex shrink-0 items-center gap-1 text-shell-ink-muted">
44 <button
45 type="button"
46 aria-label={t('shell.user.mic')}
47 aria-pressed={!micOn}
48 title={micTitle}
49 disabled={inCall && !session.canPublish}
50 onClick={() =>
51 inCall
52 ? void voiceSession.setMicrophone(!session.micEnabled)
53 : voiceSession.setMicMuted(!session.micMuted)
54 }
55 className={['workspace-icon-button is-compact', micOn ? '' : 'is-off'].filter(Boolean).join(' ')}
56 >
57 {micOn ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
58 </button>
3359 <button
3460 type="button"
61 aria-label={t('shell.user.headphones')}
62 aria-pressed={session.deafened}
63 title={session.deafened ? t('shell.user.undeafen') : t('shell.user.deafen')}
64 onClick={() => void voiceSession.setDeafened(!session.deafened)}
65 className={['workspace-icon-button is-compact', session.deafened ? 'is-off' : '']
66 .filter(Boolean)
67 .join(' ')}
68 >
69 {session.deafened ? (
70 <HeadphoneOff className="h-4 w-4" />
71 ) : (
72 <Headphones className="h-4 w-4" />
73 )}
74 </button>
75 <button
76 type="button"
3577 aria-label={t('shell.user.settings')}
3678 title={t('shell.user.settings')}
3779 onClick={onSettings}
38 className="workspace-icon-button"
80 className="workspace-icon-button is-compact"
3981 >
4082 <Settings className="h-4 w-4" />
4183 </button>
Modified web/src/shared/i18n/messages/en/shell.ts web/src/shared/i18n/messages/en/shell.ts +6 −0 View web/src/shared/i18n/messages/en/shell.ts
@@ -87,6 +87,12 @@
8787 'shell.pending.hint': 'Pick something in the workspace navigation',
8888 'shell.pending.pickChannel': 'Pick a channel on the left',
8989 'shell.user.settings': 'User settings',
90 'shell.user.mic': 'Microphone',
91 'shell.user.mute': 'Mute',
92 'shell.user.unmute': 'Unmute',
93 'shell.user.headphones': 'Headphones',
94 'shell.user.deafen': 'Deafen',
95 'shell.user.undeafen': 'Undeafen',
9096 'shell.chat.unknownUser': 'Unknown user',
9197 'shell.chat.me': 'Me',
9298 'shell.chat.userFallback': 'User {id}',
Modified web/src/shared/i18n/messages/en/space.ts web/src/shared/i18n/messages/en/space.ts +4 −0 View web/src/shared/i18n/messages/en/space.ts
@@ -196,6 +196,10 @@
196196 'space.discover.search': 'Search space names or descriptions',
197197 'space.discover.sort.members': 'Recommended',
198198 'space.discover.sort.newest': 'Newest',
199 'space.discover.cardSize': 'Card size',
200 'space.discover.cardSize.large': 'Large',
201 'space.discover.cardSize.medium': 'Medium',
202 'space.discover.cardSize.small': 'Small',
199203 'space.discover.resultCount': '{count} public spaces',
200204 'space.discover.loading': 'Loading spaces…',
201205 'space.discover.empty': 'No matching spaces',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts web/src/shared/i18n/messages/zh-CN/shell.ts +6 −0 View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -87,6 +87,12 @@
8787 'shell.pending.hint': '在工作区导航中选择内容',
8888 'shell.pending.pickChannel': '从左侧选择一个频道',
8989 'shell.user.settings': '用户设置',
90 'shell.user.mic': '麦克风',
91 'shell.user.mute': '静音',
92 'shell.user.unmute': '取消静音',
93 'shell.user.headphones': '听筒',
94 'shell.user.deafen': '关闭听筒',
95 'shell.user.undeafen': '打开听筒',
9096 'shell.chat.unknownUser': '未知用户',
9197 'shell.chat.me': '我',
9298 'shell.chat.userFallback': '用户 {id}',
Modified web/src/shared/i18n/messages/zh-CN/space.ts web/src/shared/i18n/messages/zh-CN/space.ts +4 −0 View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -196,6 +196,10 @@
196196 'space.discover.search': '搜索社区名称或简介',
197197 'space.discover.sort.members': '推荐',
198198 'space.discover.sort.newest': '最新',
199 'space.discover.cardSize': '卡片大小',
200 'space.discover.cardSize.large': '大',
201 'space.discover.cardSize.medium': '中',
202 'space.discover.cardSize.small': '小',
199203 'space.discover.resultCount': '共 {count} 个公开社区',
200204 'space.discover.loading': '正在加载社区…',
201205 'space.discover.empty': '没有匹配的社区',
Modified web/src/shared/voice/index.ts web/src/shared/voice/index.ts +1 −0 View web/src/shared/voice/index.ts
@@ -9,4 +9,5 @@
99 VoiceStatus,
1010} from './session'
1111export { useVoiceSession } from './react'
12export { rememberedVolume, rememberVolume } from './volumes'
1213export { VoiceHost, VoiceProvider } from './host'
Modified web/src/shared/voice/session.ts web/src/shared/voice/session.ts +92 −3 View web/src/shared/voice/session.ts
@@ -30,14 +30,36 @@
3030 MediaDeviceFailure,
3131 Room,
3232 RoomEvent,
33 Track,
3334 type LocalParticipant,
3435 type RemoteParticipant,
3536 type ScreenShareCaptureOptions,
3637} from 'livekit-client'
3738import { t } from '@/shared/i18n'
39import { rememberedVolume } from './volumes'
3840
3941/** Persisted so a refresh can rejoin the same channel. */
4042const STORAGE_KEY = 'gitdata.voice.session'
43/** Persisted panel toggles: they outlive the call, like the call-bar position. */
44const DEAFENED_KEY = 'gitdata.voice.deafened'
45const MIC_MUTED_KEY = 'gitdata.voice.micMuted'
46
47function readFlag(key: string): boolean {
48 try {
49 return localStorage.getItem(key) === '1'
50 } catch {
51 return false
52 }
53}
54
55function writeFlag(key: string, value: boolean): void {
56 try {
57 if (value) localStorage.setItem(key, '1')
58 else localStorage.removeItem(key)
59 } catch {
60 // A toggle that cannot persist still works for this session.
61 }
62}
4163
4264/**
4365 * Backoff for the automatic rejoin. Every attempt pulls a *new* token, which is
@@ -60,6 +82,13 @@
6082 micEnabled: boolean
6183 cameraEnabled: boolean
6284 screenShareEnabled: boolean
85 /**
86 * Deafened: every remote track is silenced locally, and the microphone follows
87 * Discord's rule of muting for as long as it lasts.
88 */
89 deafened: boolean
90 /** Muted-by-choice while not in a call: the state the next join starts from. */
91 micMuted: boolean
6392 /** False until the browser has been allowed to play remote audio. */
6493 audioPlaybackAllowed: boolean
6594 /** Local speak indicator only; remote tiles read the Room directly. */
@@ -116,6 +145,8 @@
116145 micEnabled: false,
117146 cameraEnabled: false,
118147 screenShareEnabled: false,
148 deafened: false,
149 micMuted: false,
119150 audioPlaybackAllowed: true,
120151 speaking: false,
121152 canPublish: true,
@@ -130,7 +161,13 @@
130161
131162class VoiceSession {
132163 private room: Room | null = null
133 private snapshot: VoiceSessionSnapshot = INITIAL
164 private snapshot: VoiceSessionSnapshot = {
165 ...INITIAL,
166 deafened: readFlag(DEAFENED_KEY),
167 micMuted: readFlag(MIC_MUTED_KEY),
168 }
169 /** Microphone state to restore when the deafen toggle is switched back off. */
170 private micBeforeDeafen = false
134171 private readonly listeners = new Set<Listener>()
135172 private readonly participantNames = new Map<string, string>()
136173 /** Distinguishes a user-initiated teardown from an ICE/signal drop. */
@@ -259,7 +296,10 @@
259296 if (options.audioOutputDeviceId) {
260297 await this.applyOutputDevice(room, options.audioOutputDeviceId)
261298 }
262 if (options.canPublish && options.startWithMic) {
299 // Deafened survives a reload, so a restored call comes back silent; the
300 // microphone stays off until the toggle is switched back.
301 if (this.snapshot.deafened) this.applyDeafened()
302 if (options.canPublish && options.startWithMic && !this.snapshot.deafened) {
263303 await this.setMicrophone(true, options.audioDeviceId)
264304 }
265305 if (options.canPublish && options.videoDeviceId && options.startWithMic) {
@@ -347,7 +387,16 @@
347387 const described = describeVoiceFailure(error)
348388 this.patch({ notice: described.notice, noticeDetail: described.detail })
349389 })
350 .on(RoomEvent.ParticipantConnected, () => refreshSelf())
390 .on(RoomEvent.ParticipantConnected, participant => {
391 // Somebody arriving while deafened must be silent too. Their microphone
392 // track may not exist yet, and the participant-level volume is what the
393 // SDK remembers until it does.
394 if (this.snapshot.deafened) {
395 participant.setVolume(0)
396 participant.setVolume(0, Track.Source.ScreenShareAudio)
397 }
398 refreshSelf()
399 })
351400 .on(RoomEvent.ParticipantDisconnected, () => refreshSelf())
352401 .on(RoomEvent.LocalTrackPublished, () => refreshSelf())
353402 .on(RoomEvent.LocalTrackUnpublished, () => refreshSelf())
@@ -550,6 +599,46 @@
550599 }
551600 }
552601
602 /**
603 * Headphones toggle. Silences every remote track - microphone and screen-share
604 * audio alike, including tracks that subscribe later - and mutes the microphone
605 * for as long as it lasts, which is what Discord's deafen does. Outside a call
606 * it only records the preference the next join applies.
607 */
608 async setDeafened(enabled: boolean): Promise<void> {
609 if (this.snapshot.deafened === enabled) return
610 const wasMicOn = this.snapshot.micEnabled
611 if (enabled) this.micBeforeDeafen = wasMicOn
612 this.patch({ deafened: enabled })
613 writeFlag(DEAFENED_KEY, enabled)
614 if (!this.room) return
615 this.applyDeafened()
616 if (enabled) {
617 if (wasMicOn && this.snapshot.canPublish) await this.setMicrophone(false)
618 } else if (this.micBeforeDeafen && this.snapshot.canPublish) {
619 await this.setMicrophone(true)
620 }
621 }
622
623 /** The microphone toggle outside a call: remembered until the next join. */
624 setMicMuted(muted: boolean): void {
625 this.patch({ micMuted: muted })
626 writeFlag(MIC_MUTED_KEY, muted)
627 }
628
629 /** Push the deafen flag onto every remote track currently in the room. */
630 private applyDeafened(): void {
631 const room = this.room
632 if (!room) return
633 const roomId = this.snapshot.roomId
634 for (const participant of room.remoteParticipants.values()) {
635 participant.setVolume(
636 this.snapshot.deafened ? 0 : rememberedVolume(roomId, participant.identity),
637 )
638 participant.setVolume(this.snapshot.deafened ? 0 : 1, Track.Source.ScreenShareAudio)
639 }
640 }
641
553642 /** Browsers block remote audio until a gesture; the call bar calls this. */
554643 async resumeAudio(): Promise<void> {
555644 const room = this.room
Added web/src/shared/voice/volumes.ts web/src/shared/voice/volumes.ts +27 −0 View web/src/shared/voice/volumes.ts
@@ -0,0 +1,27 @@
1/**
2 * Per-member output volume lives in the browser, not on the server: the tile menu
3 * writes it while the user drags the slider, and the deafen toggle reads the same
4 * keys back when the sound comes on again. Both go through this module so the
5 * storage format cannot drift between them.
6 */
7const VOLUME_PREFIX = 'gitdata.voice.volume.'
8
9/** Remembered volume for one member; 1 when nothing usable is stored. */
10export function rememberedVolume(roomId: string | null, identity: string): number {
11 if (!roomId) return 1
12 try {
13 const raw = Number(localStorage.getItem(VOLUME_PREFIX + roomId + '.' + identity))
14 return Number.isFinite(raw) && raw > 0 ? raw : 1
15 } catch {
16 return 1
17 }
18}
19
20/** Persist one member's volume. Best effort: memory is a convenience. */
21export function rememberVolume(roomId: string, identity: string, volume: number): void {
22 try {
23 localStorage.setItem(VOLUME_PREFIX + roomId + '.' + identity, String(volume))
24 } catch {
25 // Volume memory is a convenience, never a requirement.
26 }
27}