1:1 calls
This commit is contained in:
6
TODO.md
6
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
90
packages/shared/src/components/channel/DMCallStack.tsx
Normal file
90
packages/shared/src/components/channel/DMCallStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +61,7 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
|
||||
return (
|
||||
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
|
||||
{!compact && (
|
||||
<div className={styles.voiceHeader}>
|
||||
<div className={styles.headerLeftSection}>
|
||||
{isMobile && (
|
||||
@@ -76,10 +81,14 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
</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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user