diff --git a/TODO.md b/TODO.md index 18546c9..376a1b3 100644 --- a/TODO.md +++ b/TODO.md @@ -2,14 +2,8 @@ # Future -- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile. - - Add photo / video albums like Commit https://commet.chat/ - -- Are we using the encryped sql database to help load messages faster? - - - Notification on taskbar for windows - Notification popup for windows for discord diff --git a/packages/shared/src/components/channel/ChannelHeader.tsx b/packages/shared/src/components/channel/ChannelHeader.tsx index 1981c7c..e012e09 100644 --- a/packages/shared/src/components/channel/ChannelHeader.tsx +++ b/packages/shared/src/components/channel/ChannelHeader.tsx @@ -23,6 +23,7 @@ import { api } from '../../../../../convex/_generated/api'; import { MemberProfileModal } from '../member/MemberProfileModal'; import { MobileMemberProfileSheet } from '../member/MobileMemberProfileSheet'; import { useKeybinds } from '../../contexts/KeybindContext'; +import { useVoice } from '../../contexts/VoiceContext'; import { ChannelHeaderPinsPopover } from './ChannelHeaderPinsPopover'; import styles from './ChannelHeader.module.css'; @@ -83,6 +84,8 @@ export function ChannelHeader({ const { crypto } = usePlatform(); const { resolveStatus } = useOnlineUsers(); const keybinds = useKeybinds(); + const voice = useVoice() as any; + const isInThisDMCall = isDM && voice?.activeChannelId === channel?._id; const pinsButtonRef = useRef(null); const dmHeaderButtonRef = useRef(null); @@ -173,6 +176,27 @@ export function ChannelHeader({ setProfileOpen(true); }; + // `withVideo` intentionally unused: calling `voice.setCamera` + // right after connectToVoice resolves races the context closure + // (captured `voice` still sees room=null). The in-call control + // bar owns camera toggling. + const handleStartCall = async (_withVideo: boolean) => { + if (!voice?.connectToVoice || !channel?._id || !myUserId || !otherParticipant) { + return; + } + if (voice.activeChannelId === channel._id) return; + try { + await voice.connectToVoice( + channel._id as any, + otherParticipant.displayName, + myUserId as any, + true, + ); + } catch (e) { + console.error('Failed to start DM call:', e); + } + }; + // Wire keybinds that affect this header: toggle pins popover, // toggle member list. The KeybindProvider dispatches // `brycord:keybind:` events on the window. @@ -346,10 +370,9 @@ export function ChannelHeader({ @@ -357,10 +380,9 @@ export function ChannelHeader({ diff --git a/packages/shared/src/components/channel/ChannelView.module.css b/packages/shared/src/components/channel/ChannelView.module.css index b403147..134a26d 100644 --- a/packages/shared/src/components/channel/ChannelView.module.css +++ b/packages/shared/src/components/channel/ChannelView.module.css @@ -37,6 +37,54 @@ } } +/* ── DM 1:1 call stack ─────────────────────────────────────── */ + +/* Active DM calls share the body with the text chat: the call stage + sits on top, messages + textarea stay usable below. The divider + between them is draggable — see DMCallStack.tsx. */ +.dmCallStack { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} + +.dmCallStage { + flex: 0 0 auto; + position: relative; + overflow: hidden; +} + +.dmCallDivider { + flex: 0 0 auto; + height: 6px; + margin: -3px 0; + z-index: 10; + cursor: row-resize; + background: transparent; + position: relative; + touch-action: none; +} + +.dmCallDivider::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background: var(--user-area-divider-color); + transform: translateY(-50%); + transition: background-color 120ms; +} + +.dmCallDivider:hover::before, +.dmCallDividerDragging::before { + background: var(--brand-primary); + height: 2px; +} + /* ── Voice channel join prompt ─────────────────────────────── */ .voiceJoinPrompt { diff --git a/packages/shared/src/components/channel/ChannelView.tsx b/packages/shared/src/components/channel/ChannelView.tsx index bc93eae..23900fc 100644 --- a/packages/shared/src/components/channel/ChannelView.tsx +++ b/packages/shared/src/components/channel/ChannelView.tsx @@ -8,6 +8,7 @@ import { useIsNarrowScreen } from '../../hooks/useIsNarrowScreen'; import { ChannelChatLayout } from './ChannelChatLayout'; import { ChannelDetailsDrawer } from './ChannelDetailsDrawer'; import { ChannelHeader } from './ChannelHeader'; +import { DMCallStack } from './DMCallStack'; import { MemberListContainer } from '../member/MemberListContainer'; import { SearchPanel } from './SearchPanel'; import { VoiceCallView } from '../voice/VoiceCallView'; @@ -39,6 +40,7 @@ export function ChannelView({ channelId: channelIdProp }: { channelId?: string } const isVoiceChannel = channel?.type === 'voice'; const isDM = channel?.type === 'dm'; const isInThisVoiceCall = voice?.activeChannelId === channelId; + const isInDMCall = isDM && isInThisVoiceCall; // DMs never show a members panel — there are only two participants // and they're already represented by the DM header + yourself. @@ -87,6 +89,8 @@ export function ChannelView({ channelId: channelIdProp }: { channelId?: string } ) + ) : isInDMCall ? ( + ) : ( <> diff --git a/packages/shared/src/components/channel/DMCallStack.tsx b/packages/shared/src/components/channel/DMCallStack.tsx new file mode 100644 index 0000000..221a0e5 --- /dev/null +++ b/packages/shared/src/components/channel/DMCallStack.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ChannelChatLayout } from './ChannelChatLayout'; +import { VoiceCallView } from '../voice/VoiceCallView'; +import styles from './ChannelView.module.css'; + +const STORAGE_KEY = 'dmCallStageHeightPct'; +const MIN_PCT = 25; +const MAX_PCT = 75; +const DEFAULT_PCT = 50; + +function readStoredPct(): number { + if (typeof localStorage === 'undefined') return DEFAULT_PCT; + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_PCT; + const n = Number(raw); + if (!Number.isFinite(n)) return DEFAULT_PCT; + return Math.max(MIN_PCT, Math.min(MAX_PCT, n)); +} + +/** + * DM in-call layout: the compact VoiceCallView sits above + * ChannelChatLayout, separated by a draggable divider. The stage + * height (as a percentage of the body) is persisted to localStorage + * so the user's preferred size carries across calls and reloads. + */ +export function DMCallStack({ channelId }: { channelId: string }) { + const stackRef = useRef(null); + const [stageHeightPct, setStageHeightPct] = useState(() => readStoredPct()); + const [isDragging, setIsDragging] = useState(false); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + // Only primary-button drags — ignore touches on scrollbars etc. + if (e.button !== 0 && e.pointerType === 'mouse') return; + e.preventDefault(); + setIsDragging(true); + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + }, []); + + useEffect(() => { + if (!isDragging) return; + const onMove = (ev: PointerEvent) => { + const container = stackRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + if (rect.height <= 0) return; + const offset = ev.clientY - rect.top; + const pct = (offset / rect.height) * 100; + const clamped = Math.max(MIN_PCT, Math.min(MAX_PCT, pct)); + setStageHeightPct(clamped); + }; + const onUp = () => { + setIsDragging(false); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }, [isDragging]); + + // Persist only after the drag settles so we're not writing to + // localStorage on every pointermove frame. + useEffect(() => { + if (isDragging) return; + if (typeof localStorage === 'undefined') return; + localStorage.setItem(STORAGE_KEY, String(Math.round(stageHeightPct))); + }, [isDragging, stageHeightPct]); + + return ( +
+
+ +
+
+ +
+ ); +} diff --git a/packages/shared/src/components/voice/VoiceCallView.tsx b/packages/shared/src/components/voice/VoiceCallView.tsx index fa50d42..0f57868 100644 --- a/packages/shared/src/components/voice/VoiceCallView.tsx +++ b/packages/shared/src/components/voice/VoiceCallView.tsx @@ -10,6 +10,10 @@ import styles from './VoiceCallView.module.css'; interface VoiceCallViewProps { channelId: string; + /** DM calls render this stacked above the chat and already have + * the DM header above them — suppress the in-call header so the + * channel name isn't shown twice. */ + compact?: boolean; } interface RawParticipant { @@ -22,7 +26,7 @@ interface RawParticipant { [key: string]: any; } -export function VoiceCallView({ channelId }: VoiceCallViewProps) { +export function VoiceCallView({ channelId, compact = false }: VoiceCallViewProps) { const voice = useVoice(); const isMobile = useIsMobile(); const navigate = useNavigate(); @@ -57,29 +61,34 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) { return (
-
-
- {isMobile && ( - - )} -
- - {channelName} + {!compact && ( +
+
+ {isMobile && ( + + )} +
+ + {channelName} +
+
-
-
+ )}
- +
diff --git a/packages/shared/src/components/voice/VoiceGridLayout.module.css b/packages/shared/src/components/voice/VoiceGridLayout.module.css index a83061a..3c7f054 100644 --- a/packages/shared/src/components/voice/VoiceGridLayout.module.css +++ b/packages/shared/src/components/voice/VoiceGridLayout.module.css @@ -9,6 +9,29 @@ overflow: hidden; } +/* ── Compact (DM) layout ──────────────────────────────────────── + A centered row of circular avatars sized by VoiceGridLayout's + ResizeObserver. */ +.compactContainer { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.compactRow { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + flex-wrap: nowrap; + padding: 24px; + box-sizing: border-box; + min-width: 0; +} + .grid { --voice-grid-columns: 1; --voice-grid-gap: 12px; diff --git a/packages/shared/src/components/voice/VoiceGridLayout.tsx b/packages/shared/src/components/voice/VoiceGridLayout.tsx index 7864f57..69cef63 100644 --- a/packages/shared/src/components/voice/VoiceGridLayout.tsx +++ b/packages/shared/src/components/voice/VoiceGridLayout.tsx @@ -1,12 +1,70 @@ +import { useLayoutEffect, useRef, useState } from 'react'; import { VoiceParticipantTile, type VoiceParticipantTileData } from './VoiceParticipantTile'; import styles from './VoiceGridLayout.module.css'; interface VoiceGridLayoutProps { participants: VoiceParticipantTileData[]; + /** `compact` drops the 16:9 tile grid in favour of a centered row + * of circular avatars — used by DM calls where the call stage + * shares the viewport with the text chat. */ + variant?: 'default' | 'compact'; } -export function VoiceGridLayout({ participants }: VoiceGridLayoutProps) { +const COMPACT_MAX_AVATAR = 120; +const COMPACT_MIN_AVATAR = 48; +const COMPACT_GAP = 20; +const COMPACT_PADDING_X = 24; +const COMPACT_PADDING_Y = 24; + +export function VoiceGridLayout({ participants, variant = 'default' }: VoiceGridLayoutProps) { const tileCount = participants.length; + const containerRef = useRef(null); + const [compactAvatarSize, setCompactAvatarSize] = useState(COMPACT_MAX_AVATAR); + + // Fit circular avatars to the available stage: shrink when the + // stage is short (user dragged the splitter toward 25%) or when + // there are many participants. Runs off ResizeObserver so the + // size tracks live drags of the stage height. + useLayoutEffect(() => { + if (variant !== 'compact') return; + const el = containerRef.current; + if (!el) return; + const compute = () => { + const w = el.clientWidth; + const h = el.clientHeight; + if (!w || !h) return; + const byWidth = + (w - 2 * COMPACT_PADDING_X - (tileCount - 1) * COMPACT_GAP) / + Math.max(1, tileCount); + const byHeight = h - 2 * COMPACT_PADDING_Y; + const next = Math.max( + COMPACT_MIN_AVATAR, + Math.min(COMPACT_MAX_AVATAR, Math.floor(Math.min(byWidth, byHeight))), + ); + setCompactAvatarSize(next); + }; + compute(); + const ro = new ResizeObserver(compute); + ro.observe(el); + return () => ro.disconnect(); + }, [variant, tileCount]); + + if (variant === 'compact') { + return ( +
+
+ {participants.map((p) => ( + + ))} +
+
+ ); + } return (
diff --git a/packages/shared/src/components/voice/VoiceParticipantTile.module.css b/packages/shared/src/components/voice/VoiceParticipantTile.module.css index a8d2996..4bb94e9 100644 --- a/packages/shared/src/components/voice/VoiceParticipantTile.module.css +++ b/packages/shared/src/components/voice/VoiceParticipantTile.module.css @@ -10,6 +10,49 @@ overflow: hidden; } +/* ── Compact (DM) tile ────────────────────────────────────────── + The DM call stage shows each user as a floating circular avatar + with no tile chrome. Speaking state becomes a green ring around + the circle; mute becomes a red badge pinned to the bottom-right + corner (matches Discord's DM-call styling). */ +.compactTile { + position: relative; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + box-sizing: content-box; + transition: + box-shadow 0.4s, + outline-color 0.4s; + transition-delay: 0.5s; +} + +.compactTile[data-speaking='true'] { + box-shadow: 0 0 0 3.5px var(--voice-status-success); + transition-delay: 0s; + transition-duration: 0.2s; +} + +.compactMuteBadge { + position: absolute; + right: -2px; + bottom: -2px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28%; + height: 28%; + min-width: 18px; + min-height: 18px; + border-radius: 50%; + background-color: var(--voice-status-danger); + color: #fff; + box-shadow: 0 0 0 3px #000; + pointer-events: none; +} + .video { width: 100%; height: 100%; diff --git a/packages/shared/src/components/voice/VoiceParticipantTile.tsx b/packages/shared/src/components/voice/VoiceParticipantTile.tsx index d5629a0..3202497 100644 --- a/packages/shared/src/components/voice/VoiceParticipantTile.tsx +++ b/packages/shared/src/components/voice/VoiceParticipantTile.tsx @@ -13,11 +13,41 @@ export interface VoiceParticipantTileData { interface VoiceParticipantTileProps { participant: VoiceParticipantTileData; + /** DM calls render a round-avatar-only variant: no tile chrome, + * no name strip, and a mute badge overlayed on the avatar. + * Speaking state becomes a ring around the circle. */ + variant?: 'default' | 'compact'; + /** Pixel size for the compact circular avatar. Callers that lay + * tiles out side-by-side can pass a smaller value to fit the + * stage height. */ + compactSize?: number; } -export function VoiceParticipantTile({ participant }: VoiceParticipantTileProps) { +export function VoiceParticipantTile({ + participant, + variant = 'default', + compactSize = 96, +}: VoiceParticipantTileProps) { const { username, avatarUrl, isMuted, isSpeaking } = participant; + if (variant === 'compact') { + return ( +
+ + {isMuted && ( + + + + )} +
+ ); + } + return (