From a282fce14a2dff2c351741214c720ef55a19c2d5 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 9 Jul 2026 18:47:55 +1000 Subject: [PATCH] chore: update cinny submodule to latest dirty commit and refactor mobile swipe gesture handling --- .../mobile/MobileSwipeGestureHost.tsx | 47 +- .../components/mobile/mobile-gestures.css.ts | 29 +- overlay/src/app/components/page/Page.tsx | 203 +++ .../src/app/features/room/message/Message.tsx | 1405 +++++++++++++++++ overlay/src/app/pages/MobileFriendly.tsx | 20 + 5 files changed, 1671 insertions(+), 33 deletions(-) create mode 100644 overlay/src/app/components/page/Page.tsx create mode 100644 overlay/src/app/features/room/message/Message.tsx create mode 100644 overlay/src/app/pages/MobileFriendly.tsx 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 ( + <> + }> + + + +
+ + Delete Message + + + + +
+ + + This action is irreversible! Are you sure that you want to delete this message? + + + + Reason{' '} + + (optional) + + + + {deleteState.status === AsyncStatus.Error && ( + + Failed to delete message! Please try again. + + )} + + + +
+
+
+
+ + + ); +}); + +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 ( + <> + }> + + + +
+ + Report Message + + + + +
+ + + Report this message to server, which may then notify the appropriate people to + take action. + + + Reason + + {reportState.status === AsyncStatus.Error && ( + + Failed to report message! Please try again. + + )} + {reportState.status === AsyncStatus.Success && ( + + Message has been reported to server. + + )} + + + +
+
+
+
+ + + ); +}); + +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) && ( +
+ + + {canSendReaction && ( + { + onReactionToggle(mEvent.getId()!, key); + setEmojiBoardAnchor(undefined); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(mEvent.getId()!, mxc, shortcode); + setEmojiBoardAnchor(undefined); + }} + requestClose={() => { + setEmojiBoardAnchor(undefined); + }} + /> + } + > + + + + + )} + + + + {!isThreadedMessage && ( + onReplyClick(ev, true)} + data-event-id={mEvent.getId()} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} + {canEditEvent(mx, mEvent) && onEditId && ( + onEditId(mEvent.getId())} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} + {mEvent.getContent().body && !mEvent.isRedacted() && ( + { + const rawText = mEvent.getContent().body ?? ''; + if (rawText) copyToClipboard(rawText); + }} + variant="SurfaceVariant" + size="300" + radii="300" + title="Copy Raw Text" + > + + + )} + {shiftHeld && canDelete && ( + { + const eventId = mEvent.getId(); + if (eventId) { + mx.redactEvent(room.roomId, eventId); + } + }} + variant="Critical" + size="300" + radii="300" + title="Delete Message (Shift held)" + > + + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {canSendReaction && ( + { + onReactionToggle(mEvent.getId()!, key, shortcode); + closeMenu(); + }} + /> + )} + + {canSendReaction && ( + } + radii="300" + onClick={handleAddReactions} + > + + Add Reaction + + + )} + {relations && ( + + )} + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt); + closeMenu(); + }} + > + + Reply + + + {!isThreadedMessage && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt, true); + closeMenu(); + }} + > + + Reply in Thread + + + )} + {canEditEvent(mx, mEvent) && onEditId && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={() => { + onEditId(mEvent.getId()); + closeMenu(); + }} + > + + Edit Message + + + )} + {!hideReadReceipts && ( + + )} + {showDeveloperTools && ( + + )} + + + {canPinEvent && ( + + )} + + {((!mEvent.isRedacted() && canDelete) || + mEvent.getSender() !== mx.getUserId()) && ( + <> + + + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} + + + )} + + + } + > + + + + + + +
+ )} + {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; +}