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:
@@ -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 }} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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 chat’s 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 />
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const webRTCSupported = () =>
|
||||
['RTCPeerConnection', 'webkitRTCPeerConnection', 'mozRTCPeerConnection', 'RTCIceGatherer'].some(
|
||||
(item) => item in window
|
||||
);
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user