1:1 calls

This commit is contained in:
Bryan1029384756
2026-04-18 13:47:09 -05:00
parent 4a91415b2a
commit 938df217f4
10 changed files with 354 additions and 33 deletions

View File

@@ -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

View File

@@ -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<HTMLButtonElement>(null);
const dmHeaderButtonRef = useRef<HTMLButtonElement>(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:<id>` events on the window.
@@ -346,10 +370,9 @@ export function ChannelHeader({
<Tooltip content="Start Voice Call" placement="bottom">
<button
type="button"
className={styles.headerButton}
className={`${styles.headerButton} ${isInThisDMCall ? styles.headerButtonActive : ''}`}
aria-label="Start Voice Call"
disabled
title="Voice calls coming soon"
onClick={() => handleStartCall(false)}
>
<Phone size={20} />
</button>
@@ -357,10 +380,9 @@ export function ChannelHeader({
<Tooltip content="Start Video Call" placement="bottom">
<button
type="button"
className={styles.headerButton}
className={`${styles.headerButton} ${isInThisDMCall ? styles.headerButtonActive : ''}`}
aria-label="Start Video Call"
disabled
title="Video calls coming soon"
onClick={() => handleStartCall(true)}
>
<VideoCamera size={20} />
</button>

View File

@@ -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 {

View File

@@ -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 }
</button>
</div>
)
) : isInDMCall ? (
<DMCallStack channelId={channelId} />
) : (
<>
<ChannelChatLayout channelId={channelId} />

View File

@@ -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<HTMLDivElement | null>(null);
const [stageHeightPct, setStageHeightPct] = useState<number>(() => readStoredPct());
const [isDragging, setIsDragging] = useState(false);
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
// 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 (
<div ref={stackRef} className={styles.dmCallStack}>
<div
className={styles.dmCallStage}
style={{ height: `${stageHeightPct}%` }}
>
<VoiceCallView channelId={channelId} compact />
</div>
<div
className={`${styles.dmCallDivider} ${isDragging ? styles.dmCallDividerDragging : ''}`}
role="separator"
aria-orientation="horizontal"
aria-label="Resize call area"
onPointerDown={handlePointerDown}
/>
<ChannelChatLayout channelId={channelId} />
</div>
);
}

View File

@@ -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 (
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
<div className={styles.voiceHeader}>
<div className={styles.headerLeftSection}>
{isMobile && (
<button
type="button"
className={styles.backButton}
onClick={handleBack}
aria-label="Back to channel list"
>
<ArrowLeft size={22} weight="bold" />
</button>
)}
<div className={styles.channelInfoContainer}>
<Hash size={20} weight="bold" className={styles.channelIcon} />
<span className={styles.channelName}>{channelName}</span>
{!compact && (
<div className={styles.voiceHeader}>
<div className={styles.headerLeftSection}>
{isMobile && (
<button
type="button"
className={styles.backButton}
onClick={handleBack}
aria-label="Back to channel list"
>
<ArrowLeft size={22} weight="bold" />
</button>
)}
<div className={styles.channelInfoContainer}>
<Hash size={20} weight="bold" className={styles.channelIcon} />
<span className={styles.channelName}>{channelName}</span>
</div>
</div>
<div className={styles.headerRightSection} />
</div>
<div className={styles.headerRightSection} />
</div>
)}
<div className={styles.mainContent}>
<ScreenSharePreview />
<VoiceGridLayout participants={participants} />
<VoiceGridLayout
participants={participants}
variant={compact ? 'compact' : 'default'}
/>
</div>
<div className={styles.controlBarContainer}>

View File

@@ -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;

View File

@@ -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<HTMLDivElement | null>(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 (
<div ref={containerRef} className={styles.compactContainer}>
<div className={styles.compactRow}>
{participants.map((p) => (
<VoiceParticipantTile
key={p.userId}
participant={p}
variant="compact"
compactSize={compactAvatarSize}
/>
))}
</div>
</div>
);
}
return (
<div className={styles.gridContainer}>

View File

@@ -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%;

View File

@@ -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 (
<div
className={styles.compactTile}
data-speaking={isSpeaking ? 'true' : 'false'}
data-muted={isMuted ? 'true' : 'false'}
style={{ width: compactSize, height: compactSize }}
>
<Avatar src={avatarUrl} fallback={username} size={compactSize} />
{isMuted && (
<span className={styles.compactMuteBadge} aria-label="Muted">
<MicrophoneSlash size={Math.max(12, Math.round(compactSize * 0.22))} weight="fill" />
</span>
)}
</div>
);
}
return (
<div
className={styles.lkParticipantTile}