diff --git a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
index c1880ac..905d99d 100644
--- a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
+++ b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
@@ -22,6 +22,7 @@ type GestureState = {
startX: number;
startY: number;
messageEl: HTMLElement | null;
+ transformEl: HTMLElement | null;
messageId: string | null;
edgeBack: boolean;
offset: number;
@@ -33,6 +34,7 @@ const IDLE_STATE: GestureState = {
startX: 0,
startY: 0,
messageEl: null,
+ transformEl: null,
messageId: null,
edgeBack: false,
offset: 0,
@@ -65,6 +67,12 @@ const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
return target.closest('[data-message-id]') as HTMLElement | null;
};
+const findReplyTransformTarget = (messageEl: HTMLElement): HTMLElement => {
+ const content = messageEl.querySelector('[data-swipe-reply-content]');
+ if (content instanceof HTMLElement) return content;
+ return messageEl;
+};
+
const applyBackOffset = (dx: number, width: number): number => {
const positive = Math.max(0, dx);
if (positive <= width) return positive;
@@ -123,18 +131,18 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
- const resetReplyTransform = useCallback((messageEl?: HTMLElement | null) => {
+ const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
const bridge = mobileSwipeReplyBridgeRef.current;
- const el = messageEl ?? gestureRef.current.messageEl;
+ const el = transformEl ?? gestureRef.current.transformEl;
resetElementTransform(el);
bridge?.setIndicator(null, false);
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetGesture = useCallback(
- (messageEl?: HTMLElement | null) => {
+ (transformEl?: HTMLElement | null) => {
resetBackTransform();
- resetReplyTransform(messageEl);
+ resetReplyTransform(transformEl);
unlockScroll();
gestureRef.current = { ...IDLE_STATE };
},
@@ -150,6 +158,14 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
}, []);
+ const applyReplyTransform = useCallback(
+ (transformEl: HTMLElement, messageEl: HTMLElement, offset: number) => {
+ setElementTransform(transformEl, offset);
+ updateReplyIndicator(messageEl, offset);
+ },
+ [updateReplyIndicator]
+ );
+
const lockGesture = useCallback(
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
gestureRef.current.phase = phase;
@@ -183,9 +199,9 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
const bridge = mobileSwipeReplyBridgeRef.current;
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
- const { messageEl, messageId } = gesture;
+ const { messageEl, messageId, transformEl } = gesture;
- resetGesture(messageEl);
+ resetGesture(transformEl);
if (shouldReply && bridge) {
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
@@ -232,6 +248,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
startX: touch.clientX,
startY: touch.clientY,
messageEl: null,
+ transformEl: null,
messageId: null,
edgeBack: true,
offset: 0,
@@ -246,6 +263,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
startX: touch.clientX,
startY: touch.clientY,
messageEl: canStartReply ? messageEl : null,
+ transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
messageId: canStartReply ? messageId : null,
edgeBack: false,
offset: 0,
@@ -277,7 +295,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
if (deltaX > 0 && backEnabled) {
lockGesture('back', contentRef.current);
- } else if (deltaX < 0 && gesture.messageEl && gesture.messageId) {
+ } else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
lockGesture('reply', gesture.messageEl);
} else {
resetGesture();
@@ -296,14 +314,13 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
return;
}
- if (gesture.phase === 'reply' && gesture.messageEl) {
+ if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
if (!evt.cancelable) return;
evt.preventDefault();
const offset = Math.max(deltaX, -REPLY_MAX);
gesture.offset = offset;
- setElementTransform(gesture.messageEl, offset);
- updateReplyIndicator(gesture.messageEl, offset);
+ applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
}
};
@@ -329,7 +346,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
resetGesture();
};
- }, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, updateReplyIndicator]);
+ }, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
useEffect(() => {
resetGesture();
@@ -339,16 +356,8 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
return <>{children}>;
}
- const showBackChrome = backEnabled;
-
return (
- {showBackChrome && (
-
- )}
{children}
diff --git a/overlay/src/app/components/mobile/mobile-gestures.css.ts b/overlay/src/app/components/mobile/mobile-gestures.css.ts
index 9c5b62c..10926ff 100644
--- a/overlay/src/app/components/mobile/mobile-gestures.css.ts
+++ b/overlay/src/app/components/mobile/mobile-gestures.css.ts
@@ -13,37 +13,38 @@ export const SwipeBackRoot = style({
export const SwipeBackContent = style({
position: 'relative',
- zIndex: 1,
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
- boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
+ boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.28)',
willChange: 'transform',
touchAction: 'pan-y',
});
-export const SwipeBackUnderlay = style({
+export const CompactDetailRoot = style({
+ position: 'relative',
+ minWidth: 0,
+ overflow: 'hidden',
+});
+
+export const CompactNavUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
- pointerEvents: 'none',
+ flexDirection: 'column',
zIndex: 0,
+ pointerEvents: 'none',
});
-export const SwipeBackSidebarPeek = style({
- width: toRem(66),
- flexShrink: 0,
- backgroundColor: color.Background.Container,
- borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
-});
-
-export const SwipeBackChannelPeek = style({
- flex: 1,
+export const CompactDetailLayer = style({
+ position: 'relative',
+ zIndex: 1,
minWidth: 0,
- backgroundColor: color.Background.Container,
+ display: 'flex',
+ flexDirection: 'column',
});
export const SwipeToReplyLayer = style({
diff --git a/overlay/src/app/components/page/Page.tsx b/overlay/src/app/components/page/Page.tsx
new file mode 100644
index 0000000..020225e
--- /dev/null
+++ b/overlay/src/app/components/page/Page.tsx
@@ -0,0 +1,203 @@
+import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
+import { Box, Header, Line, Scroll, Text, as } from 'folds';
+import classNames from 'classnames';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import * as css from './style.css';
+import * as swipeCss from '../mobile/mobile-gestures.css';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
+import { useShowCompactMasterView } from '../../hooks/useCompactNav';
+
+type PageRootProps = {
+ nav: ReactNode;
+ children: ReactNode;
+};
+
+export function PageRoot({ nav, children }: PageRootProps) {
+ const screenSize = useScreenSizeContext();
+ const showCompactMaster = useShowCompactMasterView();
+ const compact = screenSize === ScreenSize.Mobile;
+
+ if (compact && showCompactMaster) {
+ return (
+
+ {nav}
+
+ );
+ }
+
+ if (compact && !showCompactMaster) {
+ return (
+
+ {nav && (
+
+ {nav}
+
+ )}
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {nav}
+ {screenSize !== ScreenSize.Mobile && (
+
+ )}
+ {children}
+
+ );
+}
+
+type ClientDrawerLayoutProps = {
+ children: ReactNode;
+};
+export function PageNav({
+ size,
+ children,
+ className,
+}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
+ const screenSize = useScreenSizeContext();
+ const isMobile = screenSize === ScreenSize.Mobile;
+
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
+ ({ className, outlined, ...props }, ref) => (
+
+ )
+);
+
+export function PageNavContent({
+ scrollRef,
+ scrollProps,
+ children,
+}: {
+ children: ReactNode;
+ scrollRef?: MutableRefObject
;
+ scrollProps?: React.ComponentProps;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export const Page = as<'div'>(({ className, ...props }, ref) => (
+
+));
+
+export const PageHeader = as<'div', css.PageHeaderVariants>(
+ ({ className, outlined, balance, ...props }, ref) => (
+
+ )
+);
+
+export const PageContent = as<'div'>(({ className, ...props }, ref) => (
+
+));
+
+export function PageHeroEmpty({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const PageHeroSection = as<'div', ComponentProps>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+export function PageHero({
+ icon,
+ title,
+ subTitle,
+ children,
+}: {
+ icon: ReactNode;
+ title: ReactNode;
+ subTitle: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {subTitle}
+
+
+ {children}
+
+ );
+}
+
+export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
+
+));
diff --git a/overlay/src/app/features/room/message/Message.tsx b/overlay/src/app/features/room/message/Message.tsx
new file mode 100644
index 0000000..6549371
--- /dev/null
+++ b/overlay/src/app/features/room/message/Message.tsx
@@ -0,0 +1,1405 @@
+import { Avatar, Box, Button, Dialog, Header, IconButton, Input, Line, Menu, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOut, RectCords, Spinner, Text, as, color, config } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
+import React, {
+ FormEventHandler,
+ MouseEventHandler,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import FocusTrap from 'focus-trap-react';
+import { useHover, useFocusWithin } from 'react-aria';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import classNames from 'classnames';
+import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
+import {
+ AvatarBase,
+ BubbleLayout,
+ CompactLayout,
+ MessageBase,
+ ModernLayout,
+ Time,
+ Username,
+ UsernameBold,
+} from '../../../components/message';
+import {
+ canEditEvent,
+ getEventEdits,
+ getMemberAvatarMxc,
+ getMemberDisplayName,
+} from '../../../utils/room';
+import {
+ getCanonicalAliasOrRoomId,
+ getMxIdLocalPart,
+ isRoomAlias,
+ mxcUrlToHttp,
+} from '../../../utils/matrix';
+import { MessageLayout, MessageSpacing } from '../../../state/settings';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
+import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
+import { emojis } from '../../../plugins/emoji';
+import * as css from './styles.css';
+import { EventReaders } from '../../../components/event-readers';
+import { TextViewer } from '../../../components/text-viewer';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { ReactionViewer } from '../reaction-viewer';
+import { MessageEditor } from './MessageEditor';
+import { UserAvatar } from '../../../components/user-avatar';
+import { copyToClipboard } from '../../../utils/dom';
+import { stopPropagation } from '../../../utils/keyboard';
+import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
+import { getViaServers } from '../../../plugins/via-servers';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
+import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
+import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
+import { PowerIcon } from '../../../components/power';
+import colorMXID from '../../../../util/colorMXID';
+import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
+import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
+import { useOtherUserColor } from '../../../hooks/useUserColor';
+
+export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
+
+type MessageQuickReactionsProps = {
+ onReaction: ReactionHandler;
+};
+export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
+ ({ onReaction, ...props }, ref) => {
+ const { getTopEmojis } = useEmojiUsage();
+ const topShortcodes = getTopEmojis(4);
+ const mostUsedEmojis = topShortcodes
+ .map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
+ .filter((e): e is IEmoji => e !== undefined);
+
+ if (mostUsedEmojis.length === 0) return ;
+ return (
+ <>
+
+ {mostUsedEmojis.map((emoji) => (
+ onReaction(emoji.unicode, emoji.shortcode)}
+ >
+ {emoji.unicode}
+
+ ))}
+
+
+ >
+ );
+ }
+);
+
+export const MessageAllReactionItem = as<
+ 'button',
+ {
+ room: Room;
+ relations: Relations;
+ onClose?: () => void;
+ }
+>(({ room, relations, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ {
+ evt.stopPropagation();
+ }}
+ open={open}
+ backdrop={}
+ >
+
+ handleClose(),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+ setOpen(false)}
+ />
+
+
+
+
+ }
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+
+ View Reactions
+
+
+ >
+ );
+});
+
+export const MessageReadReceiptItem = as<
+ 'button',
+ {
+ room: Room;
+ eventId: string;
+ onClose?: () => void;
+ }
+>(({ room, eventId, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ }>
+
+
+
+
+
+
+
+
+ }
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+
+ Read Receipts
+
+
+ >
+ );
+});
+
+export const MessageSourceCodeItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const getContent = (evt: MatrixEvent) =>
+ evt.isEncrypted()
+ ? {
+ [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
+ [`<== ORIGINAL_EVENT ==>`]: evt.event,
+ }
+ : evt.event;
+
+ const getText = (): string => {
+ const evtId = mEvent.getId()!;
+ const evtTimeline = room.getTimelineForEvent(evtId);
+ const edits =
+ evtTimeline &&
+ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
+
+ if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
+
+ const content: Record = {
+ '<== MAIN_EVENT ==>': getContent(mEvent),
+ };
+
+ edits.forEach((editEvt, index) => {
+ content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
+ });
+
+ return JSON.stringify(content, null, 2);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ }>
+
+
+
+
+
+
+
+
+ }
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+
+ View Source
+
+
+ >
+ );
+});
+
+export const MessageCopyLinkItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+
+ const handleCopy = () => {
+ const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
+ const eventId = mEvent.getId();
+ const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
+ if (!eventId) return;
+ copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers));
+ onClose?.();
+ };
+
+ return (
+ }
+ radii="300"
+ onClick={handleCopy}
+ {...props}
+ ref={ref}
+ >
+
+ Copy Link
+
+
+ );
+});
+
+export const MessageCopyRawTextItem = as<
+ 'button',
+ {
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ mEvent, onClose, ...props }, ref) => {
+ const handleCopy = () => {
+ const content = mEvent.getContent();
+ const rawText = content.body ?? '';
+ if (rawText) {
+ copyToClipboard(rawText);
+ }
+ onClose?.();
+ };
+
+ // Only show for text messages
+ const content = mEvent.getContent();
+ if (!content.body || mEvent.isRedacted()) return null;
+
+ return (
+ }
+ radii="300"
+ onClick={handleCopy}
+ {...props}
+ ref={ref}
+ >
+
+ Copy Raw Text
+
+
+ );
+});
+
+export const MessagePinItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const pinnedEvents = useRoomPinnedEvents(room);
+ const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');
+
+ const handlePin = () => {
+ const eventId = mEvent.getId();
+ const pinContent: RoomPinnedEventsEventContent = {
+ pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
+ };
+ if (!isPinned && eventId) {
+ pinContent.pinned.push(eventId);
+ }
+ mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent);
+ onClose?.();
+ };
+
+ return (
+ }
+ radii="300"
+ onClick={handlePin}
+ {...props}
+ ref={ref}
+ >
+
+ {isPinned ? 'Unpin Message' : 'Pin Message'}
+
+
+ );
+});
+
+export const MessageDeleteItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+
+ const [deleteState, deleteMessage] = useAsyncCallback(
+ useCallback(
+ (eventId: string, reason?: string) =>
+ mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
+ [mx, room]
+ )
+ );
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ const eventId = mEvent.getId();
+ if (
+ !eventId ||
+ deleteState.status === AsyncStatus.Loading ||
+ deleteState.status === AsyncStatus.Success
+ )
+ return;
+ const target = evt.target as HTMLFormElement | undefined;
+ const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+ const reason = reasonInput && reasonInput.value.trim();
+ deleteMessage(eventId, reason);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ }>
+
+
+
+
+
+
+ }
+ radii="300"
+ onClick={(evt) => {
+ if (evt.shiftKey) {
+ const eventId = mEvent.getId();
+ if (eventId) {
+ mx.redactEvent(room.roomId, eventId);
+ onClose?.();
+ }
+ } else {
+ setOpen(true);
+ }
+ }}
+ aria-pressed={open}
+ {...props}
+ ref={ref}
+ >
+
+ Delete
+
+
+ >
+ );
+});
+
+export const MessageReportItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+
+ const [reportState, reportMessage] = useAsyncCallback(
+ useCallback(
+ (eventId: string, score: number, reason: string) =>
+ mx.reportEvent(room.roomId, eventId, score, reason),
+ [mx, room]
+ )
+ );
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ const eventId = mEvent.getId();
+ if (
+ !eventId ||
+ reportState.status === AsyncStatus.Loading ||
+ reportState.status === AsyncStatus.Success
+ )
+ return;
+ const target = evt.target as HTMLFormElement | undefined;
+ const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+ const reason = reasonInput && reasonInput.value.trim();
+ if (reasonInput) reasonInput.value = '';
+ reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ }>
+
+
+
+
+
+
+ }
+ radii="300"
+ onClick={() => setOpen(true)}
+ aria-pressed={open}
+ {...props}
+ ref={ref}
+ >
+
+ Report
+
+
+ >
+ );
+});
+
+export type MessageProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ collapse: boolean;
+ highlight: boolean;
+ edit?: boolean;
+ canDelete?: boolean;
+ canSendReaction?: boolean;
+ canPinEvent?: boolean;
+ imagePackRooms?: Room[];
+ relations?: Relations;
+ messageLayout: MessageLayout;
+ messageSpacing: MessageSpacing;
+ onUserClick: MouseEventHandler;
+ onUsernameClick: MouseEventHandler;
+ onReplyClick: (
+ ev: Parameters>[0],
+ startThread?: boolean
+ ) => void;
+ onEditId?: (eventId?: string) => void;
+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+ reply?: ReactNode;
+ reactions?: ReactNode;
+ hideReadReceipts?: boolean;
+ showDeveloperTools?: boolean;
+ memberPowerTag?: MemberPowerTag;
+ accessibleTagColors?: Map;
+ legacyUsernameColor?: boolean;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+export const Message = as<'div', MessageProps>(
+ (
+ {
+ className,
+ room,
+ mEvent,
+ collapse,
+ highlight,
+ edit,
+ canDelete,
+ canSendReaction,
+ canPinEvent,
+ imagePackRooms,
+ relations,
+ messageLayout,
+ messageSpacing,
+ onUserClick,
+ onUsernameClick,
+ onReplyClick,
+ onReactionToggle,
+ onEditId,
+ reply,
+ reactions,
+ hideReadReceipts,
+ showDeveloperTools,
+ memberPowerTag,
+ accessibleTagColors,
+ legacyUsernameColor,
+ hour24Clock,
+ dateFormatString,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const senderId = mEvent.getSender() ?? '';
+ const senderPresence = useUserPresence(senderId);
+
+ const [hover, setHover] = useState(false);
+ const { hoverProps } = useHover({ onHoverChange: setHover });
+ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+ const [menuAnchor, setMenuAnchor] = useState();
+ const [emojiBoardAnchor, setEmojiBoardAnchor] = useState();
+ const [shiftHeld, setShiftHeld] = useState(false);
+
+ // Track shift key state
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') setShiftHeld(true);
+ };
+ const handleKeyUp = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') setShiftHeld(false);
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ window.addEventListener('keyup', handleKeyUp);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ window.removeEventListener('keyup', handleKeyUp);
+ };
+ }, []);
+
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+ const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
+
+ // Get custom user color from avatar metadata (takes priority)
+ const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
+
+ const tagColor = memberPowerTag?.color
+ ? accessibleTagColors?.get(memberPowerTag.color)
+ : undefined;
+ const tagIconSrc = memberPowerTag?.icon
+ ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
+ : undefined;
+
+ // Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
+ const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
+
+ const headerJSX = !collapse && (
+
+
+
+
+ {senderDisplayName}
+
+
+ {tagIconSrc && }
+
+
+ {messageLayout === MessageLayout.Modern && hover && (
+ <>
+
+ {senderId}
+
+
+ |
+
+ >
+ )}
+
+
+
+ );
+
+ const presenceClass = senderPresence?.presence === Presence.Online
+ ? css.MessageAvatarOnline
+ : senderPresence?.presence === Presence.Unavailable
+ ? css.MessageAvatarUnavailable
+ : senderPresence?.presence === Presence.Offline
+ ? css.MessageAvatarOffline
+ : undefined;
+
+ const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
+
+
+ }
+ />
+
+
+ );
+
+ const msgContentJSX = (
+
+ {reply}
+ {edit && onEditId ? (
+ onEditId()}
+ />
+ ) : (
+ children
+ )}
+ {reactions}
+
+ );
+
+ const handleContextMenu: MouseEventHandler = (evt) => {
+ if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
+ const tag = (evt.target as any).tagName;
+ if (typeof tag === 'string' && (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'img')) return;
+ evt.preventDefault();
+ setMenuAnchor({
+ x: evt.clientX,
+ y: evt.clientY,
+ width: 0,
+ height: 0,
+ });
+ };
+
+ const handleOpenMenu: MouseEventHandler = (evt) => {
+ const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+ setMenuAnchor(target.getBoundingClientRect());
+ };
+
+ const closeMenu = () => {
+ setMenuAnchor(undefined);
+ };
+
+ const handleOpenEmojiBoard: MouseEventHandler = (evt) => {
+ const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+ setEmojiBoardAnchor(target.getBoundingClientRect());
+ };
+ const handleAddReactions: MouseEventHandler = () => {
+ const rect = menuAnchor;
+ closeMenu();
+ // open it with timeout because closeMenu
+ // FocusTrap will return focus from emojiBoard
+
+ setTimeout(() => {
+ setEmojiBoardAnchor(rect);
+ }, 100);
+ };
+
+ const isThreadedMessage = mEvent.threadRootId !== undefined;
+
+ return (
+
+ {!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
+
+
+
+ )}
+ {messageLayout === MessageLayout.Compact && (
+
+ {msgContentJSX}
+
+ )}
+ {messageLayout === MessageLayout.Bubble && (
+
+ {msgContentJSX}
+
+ )}
+ {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
+
+ {headerJSX}
+ {msgContentJSX}
+
+ )}
+
+ );
+ }
+);
+
+export type EventProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ highlight: boolean;
+ canDelete?: boolean;
+ messageSpacing: MessageSpacing;
+ hideReadReceipts?: boolean;
+ showDeveloperTools?: boolean;
+};
+export const Event = as<'div', EventProps>(
+ (
+ {
+ className,
+ room,
+ mEvent,
+ highlight,
+ canDelete,
+ messageSpacing,
+ hideReadReceipts,
+ showDeveloperTools,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const [hover, setHover] = useState(false);
+ const { hoverProps } = useHover({ onHoverChange: setHover });
+ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
+ const [menuAnchor, setMenuAnchor] = useState();
+ const stateEvent = typeof mEvent.getStateKey() === 'string';
+
+ const handleContextMenu: MouseEventHandler = (evt) => {
+ if (evt.altKey || !window.getSelection()?.isCollapsed) return;
+ const tag = (evt.target as any).tagName;
+ if (typeof tag === 'string' && (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'img')) return;
+ evt.preventDefault();
+ setMenuAnchor({
+ x: evt.clientX,
+ y: evt.clientY,
+ width: 0,
+ height: 0,
+ });
+ };
+
+ const handleOpenMenu: MouseEventHandler = (evt) => {
+ const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
+ setMenuAnchor(target.getBoundingClientRect());
+ };
+
+ const closeMenu = () => {
+ setMenuAnchor(undefined);
+ };
+
+ return (
+
+ {(hover || !!menuAnchor) && (
+
+
+
+
+ setMenuAnchor(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ {!hideReadReceipts && (
+
+ )}
+ {showDeveloperTools && (
+
+ )}
+
+
+
+ {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
+ (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
+ <>
+
+
+ {!mEvent.isRedacted() && canDelete && (
+
+ )}
+ {mEvent.getSender() !== mx.getUserId() && (
+
+ )}
+
+ >
+ )}
+
+
+ }
+ >
+
+
+
+
+
+
+
+ )}
+ {children}
+
+ );
+ }
+);
diff --git a/overlay/src/app/pages/MobileFriendly.tsx b/overlay/src/app/pages/MobileFriendly.tsx
new file mode 100644
index 0000000..088931e
--- /dev/null
+++ b/overlay/src/app/pages/MobileFriendly.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from 'react';
+
+type MobileFriendlyClientNavProps = {
+ children: ReactNode;
+};
+
+/** Server sidebar stays mounted on compact detail routes for swipe-back reveal. */
+export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
+ return children;
+}
+
+type MobileFriendlyPageNavProps = {
+ path: string;
+ children: ReactNode;
+};
+
+/** Channel list stays mounted on compact detail routes for swipe-back reveal. */
+export function MobileFriendlyPageNav({ path: _path, children }: MobileFriendlyPageNavProps) {
+ return children;
+}