4acb7c7c20
When another user starts a call in a DM room, show a fixed-position notification with caller avatar, name, and Answer/Decline buttons. A Web Audio API double-pulse ring tone plays until answered, declined, or the 30-second auto-dismiss fires. - useIncomingDmCall hook: listens to MatrixRTC SessionStarted/SessionEnded, filters DM rooms (encrypted, ≤2 members), auto-dismisses after 30s, stops if caller leaves or user joins another call - IncomingCallNotification component: ring tone, caller info, themed UI for LotusGuild Terminal TDS (navy bg, orange border, neon-green Answer) and standard Cinny dark/light (CSS vars, folds Button Success/Critical) - Router.tsx: mount IncomingCallNotification inside CallEmbedProvider
308 lines
9.2 KiB
TypeScript
308 lines
9.2 KiB
TypeScript
import React, { useCallback, useEffect, useRef } from 'react';
|
|
import { Avatar, Box, Button, Text } from 'folds';
|
|
import { UserAvatar } from './user-avatar';
|
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
import { useCallStart } from '../hooks/useCallEmbed';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import { settingsAtom } from '../state/settings';
|
|
import { useIncomingDmCall } from '../hooks/useIncomingDmCall';
|
|
import { mxcUrlToHttp } from '../utils/matrix';
|
|
|
|
function useRingTone(active: boolean) {
|
|
const stopRef = useRef<(() => void) | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!active) {
|
|
stopRef.current?.();
|
|
stopRef.current = null;
|
|
return;
|
|
}
|
|
|
|
let ctx: AudioContext;
|
|
try {
|
|
ctx = new AudioContext();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const pulse = (t: number, freq: number, dur: number) => {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.frequency.value = freq;
|
|
osc.type = 'sine';
|
|
gain.gain.setValueAtTime(0, t);
|
|
gain.gain.linearRampToValueAtTime(0.22, t + 0.04);
|
|
gain.gain.setValueAtTime(0.22, t + dur - 0.04);
|
|
gain.gain.linearRampToValueAtTime(0, t + dur);
|
|
osc.start(t);
|
|
osc.stop(t + dur);
|
|
};
|
|
|
|
const ring = () => {
|
|
if (cancelled) return;
|
|
const now = ctx.currentTime;
|
|
pulse(now, 880, 0.18);
|
|
pulse(now + 0.28, 880, 0.18);
|
|
setTimeout(ring, 2200);
|
|
};
|
|
|
|
ring();
|
|
|
|
stopRef.current = () => {
|
|
cancelled = true;
|
|
ctx.close();
|
|
};
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
ctx.close();
|
|
stopRef.current = null;
|
|
};
|
|
}, [active]);
|
|
|
|
return stopRef;
|
|
}
|
|
|
|
function IncomingCallCard() {
|
|
const mx = useMatrixClient();
|
|
const startCall = useCallStart(true);
|
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
const [incoming, dismiss] = useIncomingDmCall();
|
|
const stopRingRef = useRingTone(!!incoming);
|
|
|
|
const stopAndDismiss = useCallback(() => {
|
|
stopRingRef.current?.();
|
|
dismiss();
|
|
}, [dismiss, stopRingRef]);
|
|
|
|
const handleAnswer = useCallback(() => {
|
|
if (!incoming) return;
|
|
stopRingRef.current?.();
|
|
startCall(incoming.room);
|
|
dismiss();
|
|
}, [incoming, startCall, dismiss, stopRingRef]);
|
|
|
|
if (!incoming) return null;
|
|
|
|
const callerUser = mx.getUser(incoming.callerId);
|
|
const callerName = callerUser?.displayName ?? incoming.callerId;
|
|
const avatarMxc = callerUser?.avatarUrl;
|
|
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, undefined, 64, 64, 'crop') ?? undefined : undefined;
|
|
|
|
if (lotusTerminal) {
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: '80px',
|
|
right: '24px',
|
|
zIndex: 9999,
|
|
background: '#060c14',
|
|
border: '1px solid rgba(255,107,0,0.45)',
|
|
borderRadius: '4px',
|
|
boxShadow: '0 4px 32px rgba(255,107,0,0.18), 0 0 0 1px rgba(255,107,0,0.08)',
|
|
padding: '16px',
|
|
width: '272px',
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: '10px',
|
|
letterSpacing: '0.1em',
|
|
color: '#00D4FF',
|
|
marginBottom: '10px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
display: 'inline-block',
|
|
width: '7px',
|
|
height: '7px',
|
|
borderRadius: '50%',
|
|
background: '#00FF88',
|
|
boxShadow: '0 0 6px #00FF88',
|
|
animation: 'lotus-ring-pulse 1s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
INCOMING CALL
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '14px' }}>
|
|
<Avatar size="300" style={{ border: '1px solid rgba(0,212,255,0.3)', borderRadius: '2px', overflow: 'hidden' }}>
|
|
<UserAvatar
|
|
userId={incoming.callerId}
|
|
src={avatarUrl}
|
|
alt={callerName}
|
|
renderFallback={() => (
|
|
<span style={{ fontSize: '16px', color: '#00D4FF', fontFamily: 'JetBrains Mono, monospace' }}>
|
|
{callerName[0]?.toUpperCase() ?? '?'}
|
|
</span>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
<div>
|
|
<div style={{ fontSize: '13px', color: '#e8edf5', fontWeight: 600 }}>{callerName}</div>
|
|
<div style={{ fontSize: '10px', color: 'rgba(232,237,245,0.45)', marginTop: '2px' }}>
|
|
{incoming.callerId}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
onClick={handleAnswer}
|
|
style={{
|
|
flex: 1,
|
|
background: 'transparent',
|
|
border: '1px solid #00FF88',
|
|
borderRadius: '3px',
|
|
color: '#00FF88',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
fontSize: '11px',
|
|
fontWeight: 700,
|
|
letterSpacing: '0.08em',
|
|
padding: '9px 4px',
|
|
cursor: 'pointer',
|
|
boxShadow: '0 0 10px rgba(0,255,136,0.2)',
|
|
transition: 'box-shadow 0.15s, background 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(0,255,136,0.08)';
|
|
(e.currentTarget as HTMLButtonElement).style.boxShadow = '0 0 16px rgba(0,255,136,0.35)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
|
(e.currentTarget as HTMLButtonElement).style.boxShadow = '0 0 10px rgba(0,255,136,0.2)';
|
|
}}
|
|
>
|
|
ANSWER
|
|
</button>
|
|
<button
|
|
onClick={stopAndDismiss}
|
|
style={{
|
|
flex: 1,
|
|
background: 'transparent',
|
|
border: '1px solid rgba(255,107,0,0.5)',
|
|
borderRadius: '3px',
|
|
color: '#FF6B00',
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
fontSize: '11px',
|
|
fontWeight: 700,
|
|
letterSpacing: '0.08em',
|
|
padding: '9px 4px',
|
|
cursor: 'pointer',
|
|
transition: 'opacity 0.15s, background 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,107,0,0.08)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
|
}}
|
|
>
|
|
DECLINE
|
|
</button>
|
|
</div>
|
|
<style>{`
|
|
@keyframes lotus-ring-pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.4; transform: scale(0.7); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: '80px',
|
|
right: '24px',
|
|
zIndex: 9999,
|
|
background: 'var(--bg-surface)',
|
|
border: '1px solid var(--bg-surface-border)',
|
|
borderRadius: '16px',
|
|
boxShadow: '0 8px 40px rgba(0,0,0,0.45)',
|
|
padding: '16px',
|
|
width: '272px',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: '11px',
|
|
color: 'var(--text-secondary)',
|
|
marginBottom: '10px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
display: 'inline-block',
|
|
width: '7px',
|
|
height: '7px',
|
|
borderRadius: '50%',
|
|
background: 'var(--clr-success-main)',
|
|
animation: 'lotus-ring-pulse 1s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
Incoming call
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
|
<Avatar size="300">
|
|
<UserAvatar
|
|
userId={incoming.callerId}
|
|
src={avatarUrl}
|
|
alt={callerName}
|
|
renderFallback={() => (
|
|
<Text size="H4">{callerName[0]?.toUpperCase() ?? '?'}</Text>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
<div>
|
|
<Text size="T400" style={{ fontWeight: 600 }}>{callerName}</Text>
|
|
<Text size="T200" style={{ opacity: 0.5 }}>{incoming.callerId}</Text>
|
|
</div>
|
|
</div>
|
|
<Box direction="Row" gap="200">
|
|
<Button
|
|
onClick={handleAnswer}
|
|
variant="Success"
|
|
fill="Solid"
|
|
size="400"
|
|
style={{ flex: 1 }}
|
|
>
|
|
<Text size="B400">Answer</Text>
|
|
</Button>
|
|
<Button
|
|
onClick={stopAndDismiss}
|
|
variant="Critical"
|
|
fill="Solid"
|
|
size="400"
|
|
style={{ flex: 1 }}
|
|
>
|
|
<Text size="B400">Decline</Text>
|
|
</Button>
|
|
</Box>
|
|
<style>{`
|
|
@keyframes lotus-ring-pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.4; transform: scale(0.7); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function IncomingCallNotification() {
|
|
return <IncomingCallCard />;
|
|
}
|