chore: merge v4.12.1 — security, calling, editor, media fixes

Key v4.12.1 changes merged:
- Security: sanitize-html updated to v2.17.4
- Calling: video calls in DMs/rooms, user avatars during calls, right-click to start
- Calling: IncomingCallListener with ring sound and answer/reject UI
- Editor: list crash fixes (Firefox + empty headings), codeblock filename support
- Media: URL preview hover state, keyboard nav, click-to-open, OGG audio support
- Date: ISO 8601 (YYYY-MM-DD) date format option
- Misc: stable mutual rooms endpoint, Android notification crash fix

Lotus customisations preserved:
- PiP drag/resize, DM call ring notification, PTT, GIF picker, noise suppression
- Poll voting, message forwarding, image captions, location sharing
- Lotus Terminal design theme
This commit is contained in:
root
2026-05-15 13:41:38 -04:00
54 changed files with 8502 additions and 1023 deletions
+392 -269
View File
@@ -1,6 +1,32 @@
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
/* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { config } from 'folds';
import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
color,
config,
Dialog,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
toRem,
} from 'folds';
import {
EventTimelineSetHandlerMap,
EventType,
RelationType,
Room,
RoomEvent,
} from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
@@ -8,15 +34,330 @@ import {
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getStateEvent } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum
const PIP_MIN_H = 112;
type Corner = 'se' | 'sw' | 'ne' | 'nw';
/** Normalise the element to top/left positioning so resize math is uniform. */
function normaliseToTopLeft(el: HTMLElement) {
const rect = el.getBoundingClientRect();
el.style.left = `${rect.left}px`;
el.style.top = `${rect.top}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
type IncomingCallInfo = {
room: Room;
sender: string;
senderTs: number;
lifetime: number;
intent?: string;
notificationType: RTCNotificationType;
refEventId: string;
};
type IncomingCallProps = {
dm: boolean;
info: IncomingCallInfo;
onIgnore: () => void;
onAnswer: (room: Room, video: boolean) => void;
onReject: (room: Room, eventId: string) => void;
};
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported;
const { room } = info;
const audioRef = useRef<HTMLAudioElement>(null);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
const avatarUrl = roomAvatar
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const session = useCallSession(room);
useCallMembersChange(
session,
useCallback(() => {
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
if (members.length === 0) {
onIgnore();
}
}, [room, session, onIgnore])
);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
}, []);
useEffect(() => {
if (info.notificationType === 'ring') {
playSound();
}
}, [playSound, info.notificationType]);
return (
<>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => onIgnore(),
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
<Avatar size="500" className={CallAvatarAnimation}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="400"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H3" align="Center" truncate>
{roomName}
</Text>
<Text size="T300">Incoming Call</Text>
</Box>
</Box>
{!livekitSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your homeserver does not support calling.
</Text>
)}
{!webRTCSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
)}
<Box direction="Column" gap="300">
<Button
style={{ flexGrow: 1 }}
variant="Success"
size="400"
radii="400"
onClick={() => onAnswer(room, info.intent === 'video')}
before={
<Icon
size="200"
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
filled
/>
}
disabled={!canAnswer}
>
<Text as="span" size="B400">
Answer
</Text>
</Button>
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.Cross} filled />}
>
<Text as="span" size="B400">
{dm ? 'Reject' : 'Ignore'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<audio ref={audioRef} loop style={{ display: 'none' }}>
<source src={CallSound} type="audio/ogg" />
</audio>
</>
);
}
type IncomingCallListenerProps = {
callEmbed?: CallEmbed;
joined?: boolean;
};
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events.
// we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
if (event.isEncrypted()) {
if (!event.isBeingDecrypted()) {
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
}
await event.getDecryptionPromise();
}
if (
!room ||
event.getType() !== EventType.RTCNotification ||
event.getSender() === mx.getSafeUserId() ||
!data.liveEvent
) {
return;
}
const sender = event.getSender();
const content = event.getContent<IRTCNotificationContent>();
const senderTs =
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type;
const relation =
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
const refEventId = relation?.event_id;
const mention =
content['m.mentions'].room || content['m.mentions'].user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
if (!hasCallPermission) return;
const info: IncomingCallInfo = {
room,
sender,
senderTs,
lifetime,
intent:
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
? content['m.call.intent']
: undefined,
notificationType,
refEventId,
};
setCallInfo(info);
},
[mx]
);
useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, handleTimelineEvent]);
const handleIgnore = useCallback(() => {
setCallInfo(undefined);
}, []);
const handleReject = useCallback(
(room: Room, eventId: string) => {
mx.sendEvent(room.roomId, EventType.RTCDecline, {
'm.relates_to': {
rel_type: RelationType.Reference,
event_id: eventId,
},
});
setCallInfo(undefined);
},
[mx]
);
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
startCall(room, { microphone: true, video, sound: true });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom]
);
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
return null;
}
return !joined && callInfo ? (
<IncomingCall
dm={dm}
info={callInfo}
onIgnore={handleIgnore}
onAnswer={handleAnswer}
onReject={handleReject}
/>
) : null;
}
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@@ -33,19 +374,6 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
return null;
}
type Corner = 'se' | 'sw' | 'ne' | 'nw';
/** Normalise the element to top/left positioning so resize math is uniform. */
function normaliseToTopLeft(el: HTMLDivElement) {
const rect = el.getBoundingClientRect();
el.style.top = `${rect.top}px`;
el.style.left = `${rect.left}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
}
type CallEmbedProviderProps = {
children?: ReactNode;
};
@@ -57,254 +385,81 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const selectedRoom = useSelectedRoom();
const chat = useAtomValue(callChatAtom);
const screenSize = useScreenSizeContext();
const { navigateRoom } = useRoomNavigate();
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom;
const { navigateRoom } = useRoomNavigate();
const pipDragRef = useRef<{
startX: number;
startY: number;
origLeft: number;
origTop: number;
dragged: boolean;
const pipDragRef = React.useRef<{
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
} | null>(null);
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
// Override those in PiP mode; clear them when returning so it can take back control.
useEffect(() => {
React.useEffect(() => {
const el = callEmbedRef.current;
if (!el) return;
if (pipMode) {
el.style.top = 'auto';
el.style.left = 'auto';
el.style.bottom = '72px';
el.style.right = '16px';
el.style.width = '280px';
el.style.height = '158px';
el.style.borderRadius = '12px';
el.style.overflow = 'hidden';
el.style.zIndex = '99';
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.border = '1px solid rgba(255,255,255,0.1)';
el.style.visibility = 'visible';
el.style.top = 'auto'; el.style.left = 'auto';
el.style.bottom = '72px'; el.style.right = '16px';
el.style.width = '280px'; el.style.height = '158px';
el.style.borderRadius = '12px'; el.style.overflow = 'hidden';
el.style.zIndex = '99'; el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.border = '1px solid rgba(255,255,255,0.1)'; el.style.visibility = 'visible';
} else {
el.style.top = '';
el.style.left = '';
el.style.bottom = '';
el.style.right = '';
el.style.width = '';
el.style.height = '';
el.style.borderRadius = '';
el.style.overflow = '';
el.style.zIndex = '';
el.style.boxShadow = '';
el.style.border = '';
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
el.style.visibility = callVisible ? '' : 'hidden';
}
}, [pipMode, callVisible]);
// ── Drag to move ────────────────────────────────────────────────────────────
const handlePipMouseDown = (e: React.MouseEvent) => {
const el = callEmbedRef.current;
if (!el) return;
const el = callEmbedRef.current; if (!el) return;
const rect = el.getBoundingClientRect();
pipDragRef.current = {
startX: e.clientX,
startY: e.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onMouseMove = (ev: MouseEvent) => {
pipDragRef.current = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
const onMove = (ev: MouseEvent) => {
if (!pipDragRef.current || !el) return;
const dx = ev.clientX - pipDragRef.current.startX;
const dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
const dx = ev.clientX - pipDragRef.current.startX, dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx)+Math.abs(dy) > 5) { pipDragRef.current.dragged = true; document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; }
if (pipDragRef.current.dragged) {
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.left = `${Math.max(0, Math.min(window.innerWidth-el.offsetWidth, pipDragRef.current.origLeft+dx))}px`;
el.style.top = `${Math.max(0, Math.min(window.innerHeight-el.offsetHeight, pipDragRef.current.origTop+dy))}px`;
el.style.right = 'auto'; el.style.bottom = 'auto';
}
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
};
const handlePipTouchStart = (e: React.TouchEvent) => {
const el = callEmbedRef.current;
if (!el || e.touches.length !== 1) return;
const touch = e.touches[0];
const rect = el.getBoundingClientRect();
pipDragRef.current = {
startX: touch.clientX,
startY: touch.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onTouchMove = (ev: TouchEvent) => {
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
ev.preventDefault();
const t = ev.touches[0];
const dx = t.clientX - pipDragRef.current.startX;
const dy = t.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
}
if (pipDragRef.current.dragged) {
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
const handlePipClick = (roomId: string) => {
if (pipDragRef.current?.dragged) return;
navigateRoom(roomId);
};
// ── Resize from corner handles ───────────────────────────────────────────────
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el) return;
e.stopPropagation(); e.preventDefault();
const el = callEmbedRef.current; if (!el) return;
normaliseToTopLeft(el);
const startX = e.clientX;
const startY = e.clientY;
const startW = el.offsetWidth;
const startH = el.offsetHeight;
const startL = parseFloat(el.style.left);
const startT = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`;
document.body.style.userSelect = 'none';
const onMouseMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
let w = startW;
let h = startH;
let l = startL;
let t = startT;
if (corner === 'se') { w = startW + dx; h = startH + dy; }
if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); }
if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); }
if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); }
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.left = `${l}px`;
el.style.top = `${t}px`;
const sx = e.clientX, sy = e.clientY, sw = el.offsetWidth, sh = el.offsetHeight;
const sl = parseFloat(el.style.left), st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX-sx, dy = ev.clientY-sy;
let w = sw, h = sh, l = sl, t = st;
if (corner==='se'){w=sw+dx;h=sh+dy;} if (corner==='sw'){w=sw-dx;h=sh+dy;l=sl+sw-Math.max(PIP_MIN_W,w);}
if (corner==='ne'){w=sw+dx;h=sh-dy;t=st+sh-Math.max(PIP_MIN_H,h);} if (corner==='nw'){w=sw-dx;h=sh-dy;l=sl+sw-Math.max(PIP_MIN_W,w);t=st+sh-Math.max(PIP_MIN_H,h);}
w=Math.max(PIP_MIN_W,Math.min(w,window.innerWidth)); h=Math.max(PIP_MIN_H,Math.min(h,window.innerHeight));
l=Math.max(0,Math.min(l,window.innerWidth-PIP_MIN_W)); t=Math.max(0,Math.min(t,window.innerHeight-PIP_MIN_H));
el.style.width=`${w}px`; el.style.height=`${h}px`; el.style.left=`${l}px`; el.style.top=`${t}px`;
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
// Corner handle style helper
const cornerHandle = (corner: Corner): React.CSSProperties => {
const s = corner.includes('s');
const e = corner.includes('e');
return {
position: 'absolute',
width: '18px',
height: '18px',
[s ? 'bottom' : 'top']: 0,
[e ? 'right' : 'left']: 0,
cursor: `${corner}-resize`,
zIndex: 2,
};
};
// Grip dot pattern rendered inside each handle
const gripDots = (corner: Corner) => {
const s = corner.includes('s');
const e = corner.includes('e');
// 3 dots arranged in an L toward the active corner
const dots: React.CSSProperties[] = [];
const r = 2; // dot radius px
const gap = 5;
const base = 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (i + j < 2) continue; // only the 3 corner-most dots
dots.push({
position: 'absolute',
width: r * 2,
height: r * 2,
borderRadius: '50%',
background: 'rgba(255,255,255,0.45)',
[s ? 'bottom' : 'top']: base + i * gap,
[e ? 'right' : 'left']: base + j * gap,
});
}
}
return dots;
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
};
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div
data-call-embed-container
ref={callEmbedRef}
style={{
visibility: callVisible ? undefined : 'hidden',
position: 'fixed',
@@ -313,58 +468,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
width: '100%',
height: '50%',
}}
ref={callEmbedRef}
>
{pipMode && callEmbed && (
<>
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
<div
role="button"
tabIndex={0}
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
onClick={() => handlePipClick(callEmbed.roomId)}
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
cursor: 'grab',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '6px',
}}
onClick={() => { if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId); }}
onKeyDown={(e) => e.key === 'Enter' && navigateRoom(callEmbed.roomId)}
style={{ position:'absolute', inset:0, zIndex:1, background:'transparent', cursor:'grab', display:'flex', alignItems:'flex-start', justifyContent:'flex-end', padding:'6px' }}
>
<div
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 8px',
color: '#fff',
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
}}
>
<div style={{ background:'rgba(0,0,0,0.65)', backdropFilter:'blur(4px)', borderRadius:'6px', padding:'3px 8px', color:'#fff', fontSize:'11px', fontWeight:600, pointerEvents:'none' }}>
Return to call
</div>
</div>
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
<div
key={corner}
style={cornerHandle(corner)}
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
onClick={(e) => e.stopPropagation()}
>
{gripDots(corner).map((style, i) => (
<div key={i} style={style} />
))}
</div>
{(['se','sw','ne','nw'] as Corner[]).map((corner) => (
<div key={corner} onMouseDown={(e) => handleResizeMouseDown(e, corner)} onClick={(e) => e.stopPropagation()}
style={{ position:'absolute', width:'18px', height:'18px', [corner.includes('s')?'bottom':'top']:0, [corner.includes('e')?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }} />
))}
</>
)}
+45
View File
@@ -0,0 +1,45 @@
import FocusTrap from 'focus-trap-react';
import { as, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import React, { ReactNode } from 'react';
import { ModalWide } from '../styles/Modal.css';
import { stopPropagation } from '../utils/keyboard';
export type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type ImageOverlayProps = RenderViewerProps & {
viewer: boolean;
renderViewer: (props: RenderViewerProps) => ReactNode;
};
export const ImageOverlay = as<'div', ImageOverlayProps>(
({ src, alt, viewer, requestClose, renderViewer, ...props }, ref) => (
<Overlay {...props} ref={ref} open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src,
alt,
requestClose,
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)
);
+88 -26
View File
@@ -157,10 +157,12 @@ const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): In
return children;
}
return node.childNodes.flatMap((child) => getInlineElement(child, processText));
const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
if (children.length === 0) return [{ text: '' }];
return children;
}
return [];
return [{ text: '' }];
};
const parseBlockquoteNode = (
@@ -191,7 +193,7 @@ const parseBlockquoteNode = (
if (child.name === 'p') {
appendLine();
quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
quoteLines.push(getInlineElement(child, processText));
return;
}
@@ -228,9 +230,13 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
children: [{ text }],
}));
const childCode = node.children[0];
const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
const attribs =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs : undefined;
const languageClass = attribs?.class;
const customLabel = attribs?.['data-label'];
const prefix = {
text: `${mdSequence}${customLabel ?? languageClass?.replace('language-', '') ?? ''}`,
};
const suffix = { text: mdSequence };
return [
{ type: BlockType.Paragraph, children: [prefix] },
@@ -249,10 +255,67 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
},
];
};
const parseListNode = (
const parseListMarkdown = (
node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
processText: ProcessTextCallback,
depth = 0
): ParagraphElement[] => {
const md = isTag(node) && node.name === 'ul' ? '*' : '-';
const prefix = node.attribs['data-md'] ?? md;
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
const [digitOrChar] = prefix.match(/^[\da-zA-Z]/) ?? [];
const digit = digitOrChar ? parseInt(digitOrChar, 10) : undefined;
const lines: ParagraphElement[] = [];
let lineNo = digit === undefined || Number.isNaN(digit) ? digitOrChar ?? 1 : digit;
const pushLine = (line: InlineElement[]) => {
lines.push({
type: BlockType.Paragraph,
children: [
{
text: `${Array(depth + 1).join(' ')}${starOrHyphen ? `${starOrHyphen} ` : `${lineNo}. `}`,
},
...line,
],
});
if (typeof lineNo === 'string') {
lineNo = String.fromCharCode(lineNo.charCodeAt(0) + 1);
} else {
lineNo += 1;
}
};
node.children.forEach((child) => {
if (isText(child)) {
pushLine([{ text: processText(child.data) }]);
return;
}
if (isTag(child)) {
if (child.name === 'ul' || child.name === 'ol') {
lines.push(...parseListMarkdown(child, processText, depth + 1));
return;
}
if (child.name === 'li') {
child.children.forEach((c) => {
if (isTag(c) && (c.name === 'ul' || c.name === 'ol')) {
lines.push(...parseListMarkdown(c, processText, depth + 1));
return;
}
pushLine(getInlineElement(c, processText));
});
return;
}
}
pushLine(getInlineElement(child, processText));
});
return lines;
};
const parseListLines = (children: ChildNode[], processText: ProcessTextCallback) => {
const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
@@ -263,7 +326,7 @@ const parseListNode = (
lineHolder = [];
};
node.children.forEach((child) => {
children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: processText(child.data) });
return;
@@ -277,7 +340,7 @@ const parseListNode = (
if (child.name === 'li') {
appendLine();
listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
listLines.push(getInlineElement(child, processText));
return;
}
@@ -286,24 +349,23 @@ const parseListNode = (
});
appendLine();
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({
type: BlockType.Paragraph,
children: [
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
...lineChildren,
],
}));
return listLines;
};
const parseListNode = (
node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
if (node.attribs['data-md'] !== undefined) {
return parseListMarkdown(node, processText);
}
const lines = parseListLines(node.childNodes, processText);
if (node.name === 'ol') {
return [
{
type: BlockType.OrderedList,
children: listLines.map((lineChildren) => ({
children: lines.map((lineChildren) => ({
type: BlockType.ListItem,
children: lineChildren,
})),
@@ -314,7 +376,7 @@ const parseListNode = (
return [
{
type: BlockType.UnorderedList,
children: listLines.map((lineChildren) => ({
children: lines.map((lineChildren) => ({
type: BlockType.ListItem,
children: lineChildren,
})),
@@ -325,7 +387,7 @@ const parseHeadingNode = (
node: Element,
processText: ProcessTextCallback
): HeadingElement | ParagraphElement => {
const children = node.children.flatMap((child) => getInlineElement(child, processText));
const children = getInlineElement(node, processText);
const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
@@ -388,7 +450,7 @@ export const domToEditorInput = (
appendLine();
children.push({
type: BlockType.Paragraph,
children: node.children.flatMap((child) => getInlineElement(child, processText)),
children: getInlineElement(node, processText),
});
return;
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
import { isUserId } from '../../utils/matrix';
export type OutputOptions = {
allowTextFormatting?: boolean;
@@ -215,7 +215,7 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
if (node.name === '@room') {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}
@@ -23,6 +23,11 @@ export const UrlPreviewImg = style([
objectPosition: 'center',
flexShrink: 0,
overflow: 'hidden',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.8)',
},
},
]);
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
@@ -12,6 +13,8 @@ import * as css from './UrlPreviewCard.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
const linkStyles = { color: color.Success.Main };
@@ -19,6 +22,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState(false);
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
);
@@ -30,7 +34,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mxcUrlToHttp(
const thumbUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
@@ -40,9 +44,31 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
false
);
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
return (
<>
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
{thumbUrl && (
<UrlPreviewImg
src={thumbUrl}
alt={prev['og:title']}
title={prev['og:title']}
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
onClick={() => setViewer(true)}
/>
)}
{imgUrl && (
<ImageOverlay
src={imgUrl}
alt={prev['og:title']}
viewer={viewer}
requestClose={() => {
setViewer(false);
}}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
@@ -28,7 +28,11 @@ import { copyToClipboard } from '../../utils/dom';
import { getExploreServerPath } from '../../pages/pathUtils';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
import {
useMutualRooms,
useMutualRoomsSupport,
useUnstableMutualRoomsSupport,
} from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -233,7 +237,9 @@ type MutualRoomsData = {
export function MutualRoomsChip({ userId }: { userId: string }) {
const mx = useMatrixClient();
const mutualRoomSupported = useMutualRoomsSupport();
const mutualRoomUnstable = useUnstableMutualRoomsSupport();
const mutualRoomsState = useMutualRooms(userId);
console.log(mutualRoomSupported, mutualRoomsState);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const closeUserRoomProfile = useCloseUserRoomProfile();
const directs = useDirectRooms();
@@ -279,7 +285,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
if (
userId === mx.getSafeUserId() ||
!mutualRoomSupported ||
(!mutualRoomSupported && !mutualRoomUnstable) ||
mutualRoomsState.status === AsyncStatus.Error
) {
return null;
@@ -1,6 +1,6 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Text } from 'folds';
import { Chip, Icon, Icons, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { useRoomName } from '../../hooks/useRoomMeta';
import { RoomIcon } from '../../components/room-avatar';
@@ -38,7 +38,11 @@ export function CallRoomName({ room }: CallRoomNameProps) {
variant="Background"
radii="Pill"
before={
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
dm ? (
<Icon size="200" src={Icons.VolumeHigh} filled />
) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
)
}
onClick={() => navigateRoom(room.roomId)}
>
+28 -5
View File
@@ -14,11 +14,20 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling. But you can still join call started by others.
Your homeserver does not support calling.
</Text>
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
@@ -26,16 +35,22 @@ function LivekitServerMissingMessage() {
function JoinMessage({
hasParticipant,
livekitSupported,
rtcSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (hasParticipant) return null;
if (rtcSupported === false) {
return <WebRTCMissingError />;
}
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
if (hasParticipant) return null;
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chats empty Be the first to hop in!
@@ -63,12 +78,16 @@ function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
const hasPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
@@ -77,7 +96,7 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && (livekitSupported || hasParticipant);
const canJoin = hasPermission && livekitSupported && rtcSupported;
return (
<Scroll variant="Surface" hideTrack>
@@ -100,7 +119,11 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
) : (
<NoPermissionMessage />
))}
+19 -7
View File
@@ -23,12 +23,12 @@ import { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -49,8 +49,8 @@ import {
RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useRoomName } from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
@@ -59,6 +59,8 @@ import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
type RoomNavItemMenuProps = {
room: Room;
@@ -287,8 +289,18 @@ export function RoomNavItem({
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
// Do not join if no livekit support or call is not started by others
if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) {
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
// Do not join if missing permissions or no livekit support or no webRTC support
if (!hasCallPermission || !livekitSupport(autoDiscoveryInfo) || !webRTCSupported()) {
return;
}
@@ -367,7 +379,7 @@ export function RoomNavItem({
aria-label={notificationMode}
/>
)}
{room.isCallRoom() && callMembers.length > 0 && (
{callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
const permissionGroups = usePermissionGroups(room.isCallRoom());
const permissionGroups = usePermissionGroups();
const [powerEditor, setPowerEditor] = useState(false);
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { PermissionGroup } from '../../common-settings/permissions';
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
export const usePermissionGroups = (): PermissionGroup[] => {
const groups: PermissionGroup[] = useMemo(() => {
const messagesGroup: PermissionGroup = {
name: 'Messages',
@@ -54,7 +54,7 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
state: true,
key: StateEvent.GroupCallMemberPrefix,
},
name: 'Join Call',
name: 'Start or Join Call',
},
],
};
@@ -216,13 +216,13 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
return [
messagesGroup,
...(isCallRoom ? [callSettingsGroup] : []),
callSettingsGroup,
moderationGroup,
roomOverviewGroup,
roomSettingsGroup,
otherSettingsGroup,
];
}, [isCallRoom]);
}, []);
return groups;
};
+8 -3
View File
@@ -16,21 +16,26 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom, callEmbedAtom } from '../../state/callEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
export function Room() {
const { eventId } = useParams();
const room = useRoom();
const mx = useMatrixClient();
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
const callEmbed = useAtomValue(callEmbedAtom);
const isDirect = useIsDirectRoom();
useKeyDown(
@@ -45,7 +50,7 @@ export function Room() {
)
);
const callView = room.isCallRoom() || (isDirect && !!callEmbed && callEmbed.roomId === room.roomId);
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
return (
<PowerLevelsContextProvider value={powerLevels}>
+144 -1
View File
@@ -21,6 +21,7 @@ import {
RectCords,
Badge,
Spinner,
Button,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
@@ -69,6 +70,9 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
type RoomMenuProps = {
room: Room;
@@ -254,6 +258,132 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
);
});
type CallMenuProps = {
onVoiceCall: () => void;
onVideoCall: () => void;
requestClose: () => void;
};
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
const handleVoice = () => {
onVoiceCall();
requestClose();
};
const handleVideo = () => {
onVideoCall();
requestClose();
};
return (
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
<Box direction="Column" gap="200">
<Text size="L400">Start Call</Text>
<Box direction="Column" gap="200">
<Button
size="300"
variant="Success"
fill="Soft"
outlined
radii="300"
before={<Icon size="100" src={Icons.Phone} filled />}
onClick={handleVoice}
>
<Text size="B300">Voice</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} filled />}
onClick={handleVideo}
>
<Text size="B300">Video</Text>
</Button>
</Box>
</Box>
</Menu>
);
}
);
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
const callEmbed = useCallEmbed();
const startCall = useCallStart(direct);
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
const inAnotherCall = callEmbed && !callStarted;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{inAnotherCall ? (
<Text size="L400">Already in another call End the current call to join!</Text>
) : (
<Text>Call</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
onClick={handleOpenMenu}
onContextMenu={(evt) => {
evt.preventDefault();
startCall(room, {
microphone: true,
video: true,
sound: true,
});
}}
disabled={inAnotherCall || callStarted}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<CallMenu
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
</>
);
}
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const navigate = useNavigate();
const mx = useMatrixClient();
@@ -261,6 +391,17 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const screenSize = useScreenSizeContext();
const room = useRoom();
const space = useSpaceOptionally();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const direct = useIsDirectRoom();
@@ -472,7 +613,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission && (
<CallButton />
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+1 -1
View File
@@ -286,7 +286,7 @@ export function Search({ requestClose }: SearchProps) {
gap="100"
>
<Text size="H6" align="Center">
{result ? 'No Match Found' : `No Rooms'}`}
{result ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center">
{result
+2 -1
View File
@@ -52,7 +52,7 @@ export const createCallEmbed = (
const ongoing =
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing);
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
const initialVideo = pref?.video ?? false;
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
@@ -109,6 +109,7 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
};
export const useCallMemberSoundSync = (embed: CallEmbed) => {
+4
View File
@@ -25,6 +25,10 @@ export const useDateFormatItems = (): DateFormatItem[] =>
format: 'YYYY/MM/DD',
name: 'YYYY/MM/DD',
},
{
format: 'YYYY-MM-DD',
name: 'YYYY-MM-DD',
},
{
format: '',
name: 'Custom',
+50 -6
View File
@@ -1,9 +1,10 @@
import { useCallback } from 'react';
import { MatrixClient, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
import { useSpecVersions } from './useSpecVersions';
export const useMutualRoomsSupport = (): boolean => {
export const useUnstableMutualRoomsSupport = (): boolean => {
const { unstable_features: unstableFeatures } = useSpecVersions();
const supported =
@@ -14,16 +15,59 @@ export const useMutualRoomsSupport = (): boolean => {
return !!supported;
};
export const useMutualRoomsSupport = (): boolean => {
const { unstable_features: unstableFeatures, versions } = useSpecVersions();
const supported =
versions.includes('v1.19') ||
unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms.stable'];
return !!supported;
};
type MutualRoomsOK = {
joined: string[];
next_batch?: string;
count: number;
};
const fetchAllMutualRooms = async (mx: MatrixClient, userId: string): Promise<string[]> => {
const mutualRooms: Set<string> = new Set();
let nextBatch: string | undefined;
do {
// eslint-disable-next-line no-await-in-loop
const result = await mx.http.authedRequest<MutualRoomsOK>(
Method.Get,
'/mutual_rooms',
{
user_id: userId,
from: nextBatch,
},
undefined,
{
prefix: '/_matrix/client/v1',
}
);
result.joined.forEach((r) => mutualRooms.add(r));
nextBatch = result.next_batch;
} while (typeof nextBatch === 'string');
return Array.from(mutualRooms);
};
export const useMutualRooms = (userId: string): AsyncState<string[], unknown> => {
const mx = useMatrixClient();
const supported = useMutualRoomsSupport();
const unstableSupport = useUnstableMutualRoomsSupport();
const support = useMutualRoomsSupport();
const [mutualRoomsState] = useAsyncCallbackValue(
useCallback(
() => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
[mx, userId, supported]
)
useCallback(() => {
if (support) return fetchAllMutualRooms(mx, userId);
if (unstableSupport) return mx._unstable_getSharedRooms(userId);
return Promise.resolve([]);
}, [mx, userId, unstableSupport, support])
);
return mutualRoomsState;
+1 -1
View File
@@ -57,7 +57,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
return draftPl;
});
const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
export const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
const plContent = mEvent?.getContent<IPowerLevels>();
const powerLevels = !plContent ? DEFAULT_POWER_LEVELS : fillMissingPowers(plContent);
+1 -1
View File
@@ -22,7 +22,7 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children:
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text>
Failed to connect to homeserver. Either homeserver is down or your internet.
Unable to connect to the homeserver. The homeserver or your internet connection may be down.
</Text>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
+33 -4
View File
@@ -51,12 +51,36 @@ export class CallEmbed {
private styleRetryObserver?: MutationObserver;
static getIntent(dm: boolean, ongoing: boolean): ElementCallIntent {
if (ongoing) {
return dm ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExisting;
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
if (dm && ongoing) {
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
}
if (dm) {
return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice;
}
return dm ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCall;
if (ongoing) {
return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice;
}
return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice;
}
static dmCall(intent: ElementCallIntent): boolean {
return (
intent === ElementCallIntent.JoinExistingDM ||
intent === ElementCallIntent.JoinExistingDMVoice ||
intent === ElementCallIntent.StartCallDM ||
intent === ElementCallIntent.StartCallDMVoice
);
}
static startingCall(intent: ElementCallIntent): boolean {
return (
intent === ElementCallIntent.StartCallDM ||
intent === ElementCallIntent.StartCallDMVoice ||
intent === ElementCallIntent.StartCall ||
intent === ElementCallIntent.StartCallVoice
);
}
static getWidget(
@@ -91,8 +115,13 @@ export class CallEmbed {
noiseSuppression: noiseSuppression.toString(),
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
});
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
}
const widgetUrl = new URL(
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
window.location.origin
+2
View File
@@ -1,6 +1,8 @@
export enum ElementCallIntent {
StartCall = 'start_call',
JoinExisting = 'join_existing',
StartCallVoice = 'start_call_voice',
JoinExistingVoice = 'join_existing_voice',
StartCallDM = 'start_call_dm',
JoinExistingDM = 'join_existing_dm',
StartCallDMVoice = 'start_call_dm_voice',
+3 -7
View File
@@ -15,6 +15,8 @@ export function getCallCapabilities(
capabilities.add(MatrixCapabilities.Screenshots);
capabilities.add(MatrixCapabilities.AlwaysOnScreen);
capabilities.add(MatrixCapabilities.MSC4039UploadFile);
capabilities.add(MatrixCapabilities.MSC4039DownloadFile);
capabilities.add(MatrixCapabilities.MSC3846TurnServers);
capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
@@ -78,19 +80,13 @@ export function getCallCapabilities(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
);
capabilities.add(
WidgetEventCapability.forRoomEvent(
EventDirection.Receive,
'org.matrix.msc4075.rtc.notification'
).raw
);
[
'io.element.call.encryption_keys',
'org.matrix.rageshake_request',
EventType.Reaction,
EventType.RoomRedaction,
'io.element.call.reaction',
'org.matrix.msc4075.rtc.notification',
'org.matrix.msc4310.rtc.decline',
].forEach((type) => {
capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw);
+2 -10
View File
@@ -1,12 +1,5 @@
import { replaceMatch } from '../internal';
import {
BlockQuoteRule,
CodeBlockRule,
ESC_BLOCK_SEQ,
HeadingRule,
OrderedListRule,
UnorderedListRule,
} from './rules';
import { BlockQuoteRule, CodeBlockRule, ESC_BLOCK_SEQ, HeadingRule, ListRule } from './rules';
import { runBlockRule } from './runner';
import { BlockMDParser } from './type';
@@ -23,8 +16,7 @@ export const parseBlockMD: BlockMDParser = (text, parseInline) => {
if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, ListRule, parseBlockMD, parseInline);
if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline);
// replace \n with <br/> because want to preserve empty lines
+148 -49
View File
@@ -10,14 +10,22 @@ export const HeadingRule: BlockMDRule = {
},
};
const CODEBLOCK_MD_1 = '```';
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
// opening fence: 3 or more backticks
// capture the exact fence length in group 1
// optional info string in group 2
// code content in group 3
// closing fence must match the exact same fence sequence via \1
const CODEBLOCK_REG_1 = /^(`{3,})(?!`)(\S*)\n((?:.*\n)+?)\1 *(?!.)\n?/m;
export const CodeBlockRule: BlockMDRule = {
match: (text) => text.match(CODEBLOCK_REG_1),
html: (match) => {
const [, g1, g2] = match;
const classNameAtt = g1 ? ` class="language-${g1}"` : '';
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code></pre>`;
const [, fence, g1, g2] = match;
// use last identifier after dot, e.g. for "example.json" gets us "json" as language code.
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
const filename = g1 !== langCode ? g1 : null;
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
const filenameAtt = filename ? ` data-label="${filename}"` : '';
return `<pre data-md="${fence}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
},
};
@@ -44,55 +52,146 @@ export const BlockQuoteRule: BlockMDRule = {
};
const ORDERED_LIST_MD_1 = '-';
const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
const O_LIST_START = /^([\d])\./;
const O_LIST_TYPE = /^([aAiI])\./;
const O_LIST_TRAILING_NEWLINE = /\n$/;
const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
export const OrderedListRule: BlockMDRule = {
match: (text) => text.match(ORDERED_LIST_REG_1),
html: (match, parseInline) => {
const [listText] = match;
const [, listStart] = listText.match(O_LIST_START) ?? [];
const [, listType] = listText.match(O_LIST_TYPE) ?? [];
const lines = listText
.replace(O_LIST_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
const txt = parseInline ? parseInline(line) : line;
return `<li><p>${txt}</p></li>`;
})
.join('');
const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
const startAtt = listStart ? ` start="${listStart}"` : '';
const typeAtt = listType ? ` type="${listType}"` : '';
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>${lines}</ol>`;
},
};
const UNORDERED_LIST_MD_1 = '*';
const U_LIST_ITEM_PREFIX = /^\* */;
const U_LIST_TRAILING_NEWLINE = /\n$/;
const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
export const UnorderedListRule: BlockMDRule = {
match: (text) => text.match(UNORDERED_LIST_REG_1),
const LIST_ITEM_REG = /^( *)([-*]|[\da-zA-Z]\.) +(.+)$/;
type ListType = 'ol' | 'ul';
function getListType(marker: string): ListType {
return marker === '*' ? 'ul' : 'ol';
}
function getOrderedMeta(marker: string) {
const startMatch = marker.match(/^(\d)\./);
const typeMatch = marker.match(/^([aAiI])\./);
return {
start: startMatch?.[1],
type: typeMatch?.[1],
};
}
interface ParsedLine {
indent: number;
marker: string;
content: string;
listType: ListType;
}
function parseLines(text: string): ParsedLine[] {
return text
.replace(/\n$/, '')
.split('\n')
.map((line) => {
const match = line.match(LIST_ITEM_REG);
if (!match) return null;
const [, spaces, marker, content] = match;
return {
indent: spaces.length,
marker,
content,
listType: getListType(marker),
};
})
.filter(Boolean) as ParsedLine[];
}
function openList(line: ParsedLine) {
if (line.listType === 'ul') {
return `<ul data-md="${UNORDERED_LIST_MD_1}">`;
}
const { type, start } = getOrderedMeta(line.marker);
const dataMdAtt = `data-md="${type || start || ORDERED_LIST_MD_1}"`;
const startAtt = start ? ` start="${start}"` : '';
const typeAtt = type ? ` type="${type}"` : '';
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>`;
}
function closeList(listType: ListType) {
return listType === 'ul' ? '</ul>' : '</ol>';
}
function buildList(lines: ParsedLine[], parseInline?: (s: string) => string): string {
let html = '';
const stack: ('ul' | 'ol')[] = [];
lines.forEach((line, index) => {
const prev = lines[index - 1];
const next = lines[index + 1];
const content = parseInline ? parseInline(line.content) : line.content;
// FIRST ITEM
if (!prev) {
html += openList(line);
stack.push(line.listType);
}
// DEEPER INDENT > open nested list
else if (line.indent > prev.indent) {
html += openList(line);
stack.push(line.listType);
}
// SAME LEVEL
else if (line.indent === prev.indent) {
html += '</li>';
// different list type
if (line.listType !== prev.listType) {
html += closeList(stack.pop()!);
html += openList(line);
stack.push(line.listType);
}
}
// GOING BACK UP
else if (line.indent < prev.indent) {
html += '</li>';
while (stack.length > line.indent + 1) {
html += closeList(stack.pop()!);
html += '</li>';
}
if (line.listType !== stack[stack.length - 1]) {
html += closeList(stack.pop()!);
html += openList(line);
stack.push(line.listType);
}
}
html += `<li><p>${content}</p>`;
// LAST ITEM cleanup
if (!next) {
html += '</li>';
while (stack.length) {
html += closeList(stack.pop()!);
}
}
});
return html;
}
const LIST_REG_1 = /^(?: *(?:[-*]|[\da-zA-Z]\.) +.+\n?)+/m;
export const ListRule: BlockMDRule = {
match: (text) => text.match(LIST_REG_1),
html: (match, parseInline) => {
const [listText] = match;
const lines = listText
.replace(U_LIST_TRAILING_NEWLINE, '')
.split('\n')
.map((lineText) => {
const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
const txt = parseInline ? parseInline(line) : line;
return `<li><p>${txt}</p></li>`;
})
.join('');
const lines = parseLines(listText);
return `<ul data-md="${UNORDERED_LIST_MD_1}">${lines}</ul>`;
const html = buildList(lines, parseInline);
return html;
},
};
+4 -3
View File
@@ -232,8 +232,9 @@ export function CodeBlock({
opts: HTMLReactParserOptions;
}) {
const code = children[0];
const languageClass =
code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined;
const languageClass = attribs?.class;
const customLabel = attribs?.['data-label'];
const language =
languageClass && languageClass.startsWith('language-')
? languageClass.replace('language-', '')
@@ -262,7 +263,7 @@ export function CodeBlock({
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
<Box grow="Yes">
<Text size="L400" truncate>
{language ?? 'Code'}
{customLabel ?? language ?? 'Code'}
</Text>
</Box>
<Box shrink="No" gap="200">
+7 -1
View File
@@ -1,7 +1,13 @@
import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
export type DateFormat =
| 'D MMM YYYY'
| 'DD/MM/YYYY'
| 'MM/DD/YYYY'
| 'YYYY/MM/DD'
| 'YYYY-MM-DD'
| '';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
export type ChatBackground = 'none' | 'blueprint' | 'carbon' | 'stars' | 'topographic' | 'herringbone' | 'crosshatch' | 'chevron' | 'polka' | 'triangles' | 'plaid' | 'tactical' | 'circuit' | 'hexgrid';
export enum MessageLayout {
+47
View File
@@ -0,0 +1,47 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
const wobble = keyframes({
'0%': {
transform: 'translateX(0) rotateZ(0deg)',
},
'20%': {
transform: `translateX(-${toRem(4)}) rotateZ(-4deg)`,
},
'40%': {
transform: `translateX(${toRem(4)}) rotateZ(4deg)`,
},
'60%': {
transform: `translateX(-${toRem(3)}) rotateZ(-3deg)`,
},
'80%': {
transform: `translateX(${toRem(3)}) rotateZ(3deg)`,
},
'100%': {
transform: 'translateX(0) rotateZ(0deg)',
},
});
const glowPulse = keyframes({
'0%': {
boxShadow: `0 0 0 ${toRem(0)} ${color.Success.ContainerActive}`,
},
'100%': {
boxShadow: `0 0 0 ${toRem(8)} ${color.Success.ContainerActive}`,
},
});
export const WobbleAnimation = style({
animation: `${wobble} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
});
export const GlowAnimation = style({
animation: `${glowPulse} 2000ms ease-out`,
animationIterationCount: 'infinite',
});
export const CallAvatarAnimation = style({
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
animationIterationCount: 'infinite',
});
+11
View File
@@ -120,12 +120,23 @@ export const CodeBlockBottomShadow = style({
background: `linear-gradient(to top, #00000022, #00000000)`,
});
const BaseList = style({});
export const List = style([
BaseList,
DefaultReset,
MarginSpaced,
{
padding: `0 ${config.space.S100}`,
paddingLeft: config.space.S600,
selectors: {
'& &': {
marginTop: config.space.S200,
marginBottom: config.space.S200,
},
'li:last-child &': {
marginBottom: 0,
},
},
},
]);
+9 -1
View File
@@ -233,7 +233,15 @@ export const notificationPermission = (permission: NotificationPermission) => {
if ('Notification' in window) {
return window.Notification.permission === permission;
}
return false;
try {
// https://stackoverflow.com/questions/29774836/failed-to-construct-notification-illegal-constructor
// https://issues.chromium.org/issues/40415865
// eslint-disable-next-line no-new
new Notification('');
} catch {
return false;
}
return true;
};
export const getMouseEventCords = (event: MouseEvent) => ({
+5
View File
@@ -31,6 +31,7 @@ export const APPLICATION_MIME_TYPES = [
'application/javascript',
'application/xhtml+xml',
'application/xml',
'application/ogg',
];
export const TEXT_MIME_TYPE = [
@@ -115,6 +116,10 @@ export const getBlobSafeMimeType = (mimeType: string) => {
if (type === 'video/quicktime') {
return 'video/mp4';
}
// Fixes missing playback for Ogg audio
if (type === 'application/ogg') {
return 'audio/ogg';
}
return type;
};
+4
View File
@@ -0,0 +1,4 @@
export const webRTCSupported = () =>
['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some(
(item) => item in window
);
+1 -1
View File
@@ -71,7 +71,7 @@ const permittedTagToAttributes = {
ul: ['data-md'],
a: ['name', 'target', 'href', 'rel', 'data-md'],
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
code: ['class', 'data-md'],
code: ['class', 'data-md', 'data-label'],
strong: ['data-md'],
i: ['data-md'],
em: ['data-md'],
+6
View File
@@ -129,3 +129,9 @@ textarea {
audio:not([controls]) {
display: none !important;
}
/* Fix Firefox rendering lists that have empty items with those items collapsed in on eachother */
li p::before {
content: '';
display: inline-block;
}