diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index d4a547298..9e151e94f 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -44,8 +44,8 @@ export function SwipeableChatWrapper({ if (active) { x.set(val); } else { - const swipeThreshold = 120; - const velocityThreshold = 0.5; + const swipeThreshold = 180; + const velocityThreshold = 1.2; if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) { onOpenSidebar?.(); diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx index 58fc9293e..9ae30cb99 100644 --- a/src/app/components/SwipeableMessageWrapper.tsx +++ b/src/app/components/SwipeableMessageWrapper.tsx @@ -18,9 +18,9 @@ function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onRepl if (active) { const val = mx < 0 ? mx : 0; x.set(Math.max(-80, val)); - if (mx < -50 !== isReady) setIsReady(mx < -50); + if (mx < -80 !== isReady) setIsReady(mx < -80); } else { - if (mx < -50) onReply(); + if (mx < -80) onReply(); x.set(0); setIsReady(false); } diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx index a77b802f5..477919845 100644 --- a/src/app/components/SwipeableOverlayWrapper.tsx +++ b/src/app/components/SwipeableOverlayWrapper.tsx @@ -40,8 +40,8 @@ export function SwipeableOverlayWrapper({ if (active) { x.set(val); } else { - const swipeThreshold = 100; - const velocityThreshold = 0.5; + const swipeThreshold = 150; + const velocityThreshold = 1.2; const swipedLeft = direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0)); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index e76cce520..504256a51 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -2,7 +2,7 @@ import type { ClipboardEventHandler, KeyboardEventHandler, ReactNode } from 'rea import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Scroll, Text } from 'folds'; import type { Descendant, Editor } from 'slate'; -import { Node, createEditor } from 'slate'; +import { Node, Transforms, createEditor } from 'slate'; import type { RenderLeafProps, RenderElementProps, RenderPlaceholderProps } from 'slate-react'; import { Slate, Editable, withReact, ReactEditor } from 'slate-react'; import { withHistory } from 'slate-history'; @@ -440,10 +440,35 @@ export const CustomEditor = forwardRef( onPaste={onPaste} // Defer to OS capitalization setting (respects iOS sentence-case toggle). autoCapitalize="sentences" + // Enables autocorrect on iOS, which also helps autocapitalization work. + autoCorrect="on" // keeps focus after pressing send. onBlur={() => { if (mobileOrTablet()) ReactEditor.focus(editor); }} + // iOS Slate.js bug: an empty contenteditable doesn't signal + // "start of sentence" to autocapitalize. A two-frame round-trip + // (insert space, then delete it one frame later) lets iOS process + // the intermediate "has text" state before seeing the empty field + // again — this is what triggers sentence-case on the next keystroke. + // + // Guard: skip the trick when triggerAutoCapitalize is returning focus + // to Slate (justRestoredFocusRef is set synchronously before the + // ReactEditor.focus call). Without this guard the space→delete fires + // again, handleChange sees non-empty→empty while focused, calls + // triggerAutoCapitalize again, and the placeholder flashes in a + // tight loop every time a reply is started. + onFocus={() => { + if (!mobileOrTablet()) return; + if (justRestoredFocusRef.current) return; + requestAnimationFrame(() => { + if (!ReactEditor.isFocused(editor) || Node.string(editor).length > 0) return; + Transforms.insertText(editor, ' '); + requestAnimationFrame(() => { + Transforms.delete(editor, { reverse: true }); + }); + }); + }} /> {(hasAfter || showResponsiveAfterInline) && ( diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index d209b2d0a..094729ce3 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -380,6 +380,8 @@ type EmojiBoardProps = { imagePackRooms: Room[]; requestClose: () => void; returnFocusOnDeactivate?: boolean; + /** Controls whether the FocusTrap is active. Pass false when rendering but hiding the board. */ + active?: boolean; onEmojiSelect?: (unicode: string, shortcode: string) => void; onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; @@ -393,6 +395,7 @@ export function EmojiBoard({ imagePackRooms, requestClose, returnFocusOnDeactivate, + active = true, onEmojiSelect, onCustomEmojiSelect, onStickerSelect, @@ -534,6 +537,7 @@ export function EmojiBoard({ return ( ([]); - const containerRef = useRef(null); log.log('[Banner] Component render, queue length:', queue.length, 'banner:', banner); - // Adjust banner position for iOS keyboard - useEffect(() => { - // Only apply on iOS/browsers that support visualViewport - if (!('visualViewport' in window)) return undefined; - - const updatePosition = () => { - const container = containerRef.current; - if (!container) return; - - const visualViewport = window.visualViewport!; - // Calculate how much of the screen is covered by the keyboard - // When keyboard opens, visualViewport.height shrinks - const keyboardHeight = window.innerHeight - visualViewport.height; - - // Position the banner down by the keyboard height so it appears at the top of the visible area - // This puts it "halfway down the page" when keyboard covers half the screen - if (keyboardHeight > 0) { - container.style.top = `${keyboardHeight}px`; - } else { - // Reset to CSS default (env(safe-area-inset-top)) - container.style.top = ''; - } - }; - - const visualViewport = window.visualViewport!; - visualViewport.addEventListener('resize', updatePosition); - visualViewport.addEventListener('scroll', updatePosition); - updatePosition(); // Initial position - - return () => { - visualViewport.removeEventListener('resize', updatePosition); - visualViewport.removeEventListener('scroll', updatePosition); - }; - }, []); - // Push new notifications into the local queue. useEffect(() => { if (!banner) return; @@ -247,7 +211,7 @@ export function NotificationBanner() { log.log('[Banner] Rendering', queue.length, 'banners'); return ( -
+
{queue.map((n) => ( ))} diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 8a5472c48..49be2de5b 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -60,7 +60,8 @@ export const PageNavContent = style({ minHeight: '100%', padding: config.space.S200, paddingRight: 0, - paddingBottom: config.space.S700, + // Ensure the last nav item is always above the home indicator / Android nav bar. + paddingBottom: `max(${config.space.S700}, env(safe-area-inset-bottom, 0px))`, }); export const PageHeader = recipe({ diff --git a/src/app/components/splash-screen/SplashScreen.css.ts b/src/app/components/splash-screen/SplashScreen.css.ts index bd3c300a7..ecc90af12 100644 --- a/src/app/components/splash-screen/SplashScreen.css.ts +++ b/src/app/components/splash-screen/SplashScreen.css.ts @@ -2,11 +2,16 @@ import { style } from '@vanilla-extract/css'; import { color, config } from 'folds'; export const SplashScreen = style({ - minHeight: '100%', + flexGrow: 1, backgroundColor: color.Background.Container, color: color.Background.OnContainer, }); export const SplashScreenFooter = style({ - padding: config.space.S400, + paddingTop: config.space.S400, + paddingLeft: config.space.S400, + paddingRight: config.space.S400, + // Ensure footer clears the home indicator / Android nav bar. + // Falls back to S400 on devices without a bottom safe area. + paddingBottom: `max(${config.space.S400}, env(safe-area-inset-bottom, 0px))`, }); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c6b2ab2f4..6e4834452 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1,5 +1,6 @@ import type { KeyboardEventHandler, MouseEvent, RefObject } from 'react'; import { forwardRef, useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; @@ -67,7 +68,6 @@ import { } from '$components/editor'; import { plainToEditorInput } from '$components/editor/input'; import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; -import { UseStateProvider } from '$components/UseStateProvider'; import type { TUploadContent } from '$utils/matrix'; import { encryptFile, getImageInfo, mxcUrlToHttp, toggleReaction } from '$utils/matrix'; import { useTypingStatusUpdater } from '$hooks/useTypingStatusUpdater'; @@ -143,6 +143,7 @@ import { import { ImageUsage } from '$plugins/custom-emoji'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import { useKeyboardHeight, useScrollLock } from '$hooks/ios-keyboard-fix'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -276,6 +277,24 @@ export const RoomInput = forwardRef( const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat'); const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying'); const emojiBtnRef = useRef(null); + // Hoisted from the UseStateProvider in JSX so EmojiBoard can be kept mounted + // after first open (avoids re-initializing virtualizer on every open). + const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); + const [emojiBoardAnchorRect, setEmojiBoardAnchorRect] = useState(null); + const openEmojiBoard = useCallback((tab: EmojiBoardTab) => { + const rect = emojiBtnRef.current?.getBoundingClientRect() ?? null; + setEmojiBoardAnchorRect(rect); + setEmojiBoardTab(tab); + }, []); + const closeEmojiBoard = useCallback(() => { + setEmojiBoardTab((t) => { + if (t) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return t; + }); + }, [editor]); const micBtnRef = useRef(null); // Preserve stable list keys across metadata/description replacements without // storing UI-only IDs in the upload draft state. @@ -347,6 +366,12 @@ export const RoomInput = forwardRef( }, }) ); + // If all files failed to encrypt (e.g. iCloud file not yet downloaded + // on iOS), surface an error rather than silently producing no items. + if (fileItems.length === 0 && safeFiles.length > 0) { + setSendError('Could not read the file. Try downloading it first, then try again.'); + return; + } } else { safeFiles.forEach((f) => fileItems.push({ @@ -389,6 +414,14 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); + const { triggerPreLift } = useKeyboardHeight(); + // Always active on mobile: iOS can apply window.scrollY even with overflow:hidden + // on body (scroll-prediction bug). The lock snaps scrollY back to 0 immediately + // on any scroll event, preventing the "header scrolls up then snaps" jank. + // useKeyboardHeight now manages --sable-visible-height synchronously in its own + // event handler, so no useEffect here is needed for CSS variable management. + useScrollLock(mobileOrTablet()); + useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), useCallback((width) => setHideStickerBtn(width < 500), []) @@ -1269,7 +1302,8 @@ export const RoomInput = forwardRef( }; return ( -
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{selectedFiles.length > 0 && ( ( - - {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( - { - setEmojiBoardTab((t) => { - if (t) { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return undefined; - } - return t; - }); - }} - /> - } + {/* Emoji/sticker board: kept mounted after first open to avoid re-initialising + the virtualizer on every open. FocusTrap is deactivated when hidden. */} + {emojiBoardAnchorRect && + createPortal( +
{ + const rawRight = window.innerWidth - emojiBoardAnchorRect.right; + const boardWidth = Math.min(432, window.innerWidth - 32); + return Math.max(0, Math.min(rawRight, window.innerWidth - boardWidth)); + })(), + display: emojiBoardTab !== undefined ? undefined : 'none', + }} > - {!hideStickerBtn && ( - setEmojiBoardTab(EmojiBoardTab.Sticker)} - variant="SurfaceVariant" - size="300" - radii="300" - title="open sticker picker" - aria-label="Open sticker picker" - > - - - )} - setEmojiBoardTab(EmojiBoardTab.Emoji)} - variant="SurfaceVariant" - size="300" - radii="300" - title="open emoji picker" - aria-label="Open emoji picker" - > - - - + +
, + document.body )} -
+ {!hideStickerBtn && ( + openEmojiBoard(EmojiBoardTab.Sticker)} + variant="SurfaceVariant" + size="300" + radii="300" + title="open sticker picker" + aria-label="Open sticker picker" + > + + + )} + openEmojiBoard(EmojiBoardTab.Emoji)} + variant="SurfaceVariant" + size="300" + radii="300" + title="open emoji picker" + aria-label="Open emoji picker" + > + + prev && atBottom) { + lastProgrammaticBottomPinAtRef.current = Date.now(); + vListRef.current?.scrollTo(vListRef.current.scrollSize); + } prevViewportHeightRef.current = newHeight; }); diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a5329bed4..673aea514 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -160,7 +160,14 @@ export function RoomView({ eventId }: { eventId?: string }) { - + {canMessage && delayedEventsSupported && ( )} diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index e09b76ec0..9e0aa3a67 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -242,6 +242,10 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(threadRootId)); const replyDraft = useAtomValue(roomIdToReplyDraftAtomFamily(threadRootId)); const activeReplyId = replyDraft?.eventId; + // Keep a ref so handleReplyClick can read the latest draft without being + // recreated on every keystroke (which would re-render all Message instances). + const replyDraftRef = useRef(replyDraft); + replyDraftRef.current = replyDraft; // User profile popup const openUserRoomProfile = useOpenUserRoomProfile(); @@ -593,7 +597,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra }; // Only toggle off if we're actively replying to this event (non-empty body distinguishes // a real reply draft from the seeded base-thread draft, which has body: ''). - if (activeReplyId === replyId && replyDraft?.body) { + if (activeReplyId === replyId && replyDraftRef.current?.body) { // Toggle off — reset to base thread draft setReplyDraft({ userId: mx.getUserId() ?? '', @@ -606,7 +610,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra } } }, - [mx, room, setReplyDraft, activeReplyId, threadRootId, replyDraft] + [mx, room, setReplyDraft, activeReplyId, threadRootId] ); const handleReactionToggle = useCallback( diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 3224480c6..62a3c7e0f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -82,6 +82,7 @@ import { import type { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile'; import { convertBeeperFormatToOurPerMessageProfile } from '$hooks/usePerMessageProfile'; import { MessageEditor } from './MessageEditor'; +import { MobileMessageMenu } from './MobileMessageMenu'; import * as css from './styles.css'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -158,6 +159,40 @@ export const MessageCopyLinkItem = as< ); }); +export const MessageCopyTextItem = as< + 'button', + { + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ mEvent, onClose, ...props }, ref) => { + const content = mEvent.getContent(); + // For edited messages, prefer the new content body + const body: string | undefined = content['m.new_content']?.body ?? content.body; + + if (!body || mEvent.isRedacted()) return null; + + const handleCopy = () => { + copyToClipboard(body); + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handleCopy} + {...props} + ref={ref} + > + + Copy Text + + + ); +}); + // message pinning export const MessagePinItem = as< 'button', @@ -254,22 +289,66 @@ export type MessageProps = { msc2723ForwardedMessageProps?: MSC2723ForwardedMessageProps; }; -function useMobileDoubleTap(callback: () => void, delay = 300) { - const lastTapRef = useRef(0); +function useMobileLongPress(callback: () => void, delay = 500) { + const timerRef = useRef | null>(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const firedRef = useRef(false); + + const cancel = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + startPosRef.current = null; + }, []); + + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!mobileOrTablet()) return; + const touch = e.touches[0]; + if (!touch) return; + startPosRef.current = { x: touch.clientX, y: touch.clientY }; + firedRef.current = false; + timerRef.current = setTimeout(() => { + timerRef.current = null; + firedRef.current = true; + // Clear any text selection the browser started during the long-press gesture. + window.getSelection()?.removeAllRanges(); + callback(); + }, delay); + }, + [callback, delay] + ); + + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!startPosRef.current) return; + const touch = e.touches[0]; + if (!touch) return; + const dx = touch.clientX - startPosRef.current.x; + const dy = touch.clientY - startPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > 10) cancel(); + }, + [cancel] + ); - return useCallback(() => { - if (!mobileOrTablet()) return; + const onTouchEnd = useCallback(() => { + cancel(); + }, [cancel]); - const now = Date.now(); - const timeSinceLastTap = now - lastTapRef.current; + // Prevent the browser from selecting message text during a long-press gesture. + // Only applied on touch devices — desktop users can still select text normally. + const style = mobileOrTablet() + ? ({ userSelect: 'none', WebkitUserSelect: 'none' } as React.CSSProperties) + : undefined; - if (timeSinceLastTap < delay && timeSinceLastTap > 0) { - callback(); - lastTapRef.current = 0; - } else { - lastTapRef.current = now; - } - }, [callback, delay]); + return { + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel: onTouchEnd, + style, + }; } const clamp = (str: string, len: number) => (str.length > len ? `${str.slice(0, len)}...` : str); @@ -488,8 +567,6 @@ function MessageInternal( const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false); const optionsRef = useRef(null); - - const [showPronouns] = useSetting(settingsAtom, 'showPronouns'); const [parsePronouns] = useSetting(settingsAtom, 'parsePronouns'); const [useRightBubbles] = useSetting(settingsAtom, 'useRightBubbles'); @@ -515,17 +592,6 @@ function MessageInternal( return existing; }, [pronouns, inlinePronoun]); - useEffect(() => { - if (!mobileOptionsOpen) return undefined; - const handleClickOutside = (e: globalThis.Event) => { - if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) { - setMobileOptionsOpen(false); - } - }; - document.addEventListener('pointerdown', handleClickOutside, { capture: true }); - return () => document.removeEventListener('pointerdown', handleClickOutside, { capture: true }); - }, [mobileOptionsOpen]); - const headerJSX = !collapse && ( {cleanedDisplayName} - {showPronouns && ( + {mergedPronouns.length > 0 && ( )} {showPmPInfo && ( @@ -787,6 +853,7 @@ function MessageInternal( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -842,7 +909,7 @@ function MessageInternal( onReplyClick(mockEvent); }; - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -872,7 +939,7 @@ function MessageInternal( {...focusWithinProps} ref={ref} > - {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor || mobileOptionsOpen) && ( + {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor) && (
@@ -1106,6 +1173,7 @@ function MessageInternal( )} + {canPinEvent && ( @@ -1228,7 +1296,7 @@ function MessageInternal( {messageLayout === MessageLayout.Compact && ( -
{msgContentJSX}
+
{msgContentJSX}
)} @@ -1240,20 +1308,36 @@ function MessageInternal( onContextMenu={handleContextMenu} align={useRightBubbles && senderId === mx.getUserId() ? 'right' : 'left'} > -
{msgContentJSX}
+
{msgContentJSX}
)} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( -
+
{headerJSX} {msgContentJSX}
)} + {mobileOptionsOpen && ( + setMobileOptionsOpen(false)} + /> + )} ); } @@ -1305,6 +1389,7 @@ export const Event = as<'div', EventProps>( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -1348,18 +1433,7 @@ export const Event = as<'div', EventProps>( const optionsRef = useRef(null); - useEffect(() => { - if (!mobileOptionsOpen) return undefined; - const handleClick = (e: globalThis.Event) => { - if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) { - setMobileOptionsOpen(false); - } - }; - document.addEventListener('pointerdown', handleClick, { capture: true }); - return () => document.removeEventListener('pointerdown', handleClick, { capture: true }); - }, [mobileOptionsOpen]); - - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1384,7 +1458,7 @@ export const Event = as<'div', EventProps>( {...focusWithinProps} ref={ref} > - {(isDesktopHover || !!menuAnchor || mobileOptionsOpen) && ( + {(isDesktopHover || !!menuAnchor) && (
@@ -1444,6 +1518,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || @@ -1488,9 +1563,22 @@ export const Event = as<'div', EventProps>(
)} -
+
{children}
+ {mobileOptionsOpen && ( + {}} + imagePackRooms={[]} + onClose={() => setMobileOptionsOpen(false)} + /> + )} ); } diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts new file mode 100644 index 000000000..270ed1b76 --- /dev/null +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -0,0 +1,141 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +const slideUp = keyframes({ + from: { transform: 'translateY(100%)' }, + to: { transform: 'translateY(0)' }, +}); + +export const Backdrop = style({ + position: 'fixed', + inset: 0, + // Theme-scrim overlay dims the timeline behind the sheet, just like Discord does. + background: color.Other.Overlay, + zIndex: 100, +}); + +export const Sheet = style([ + DefaultReset, + { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: 101, + background: color.Surface.Container, + borderRadius: `${toRem(16)} ${toRem(16)} 0 0`, + paddingBottom: `max(${config.space.S400}, env(safe-area-inset-bottom))`, + boxShadow: '0 -4px 24px rgba(0,0,0,0.18)', + animation: `${slideUp} 220ms cubic-bezier(0.4, 0, 0.2, 1)`, + maxHeight: '80vh', + overflowY: 'auto', + }, +]); + +export const Handle = style({ + width: toRem(36), + height: toRem(4), + background: color.SurfaceVariant.ContainerLine, + borderRadius: toRem(2), + margin: `${config.space.S200} auto ${config.space.S100}`, +}); + +export const ReactionsRow = style({ + display: 'flex', + gap: config.space.S200, + padding: `${config.space.S300} ${config.space.S400}`, + justifyContent: 'center', + flexWrap: 'wrap', +}); + +export const ReactionBtn = style({ + fontSize: toRem(28), + lineHeight: 1, + padding: config.space.S100, + background: color.SurfaceVariant.Container, + border: 'none', + borderRadius: '50%', + cursor: 'pointer', + minWidth: toRem(48), + minHeight: toRem(48), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + selectors: { + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + +// A rounded-card group for visually separating action sections, like Discord. +export const ActionGroup = style({ + margin: `0 ${config.space.S300} ${config.space.S300}`, + borderRadius: toRem(12), + background: color.SurfaceVariant.Container, + overflow: 'hidden', +}); + +export const ActionItem = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + padding: `${config.space.S300} ${config.space.S400}`, + cursor: 'pointer', + background: 'transparent', + border: 'none', + width: '100%', + textAlign: 'left', + color: color.Surface.OnContainer, + selectors: { + // Separator between adjacent items inside a group + '& + &': { + borderTop: `1px solid ${color.SurfaceVariant.ContainerLine}`, + }, + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + +export const ActionItemDanger = style({ + color: color.Critical.Main, +}); + +export const EmojiPickerHeader = style({ + display: 'flex', + alignItems: 'center', + padding: `${config.space.S200} ${config.space.S300}`, + borderBottom: `1px solid ${color.SurfaceVariant.ContainerLine}`, +}); + +export const EmojiPickerBackBtn = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: toRem(36), + height: toRem(36), + background: 'transparent', + border: 'none', + borderRadius: '50%', + cursor: 'pointer', + color: color.Surface.OnContainer, + flexShrink: 0, + selectors: { + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + +export const EmojiPickerTitle = style({ + flexGrow: 1, + textAlign: 'center', + marginRight: toRem(36), // balance the back button width +}); + +export const EmojiPickerWrap = style({ + display: 'flex', + justifyContent: 'center', + padding: config.space.S200, +}); diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx new file mode 100644 index 000000000..6c1968ca1 --- /dev/null +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -0,0 +1,434 @@ +import { createPortal } from 'react-dom'; +import { Icon, Icons, Text } from 'folds'; +import type { MouseEventHandler, ReactNode, TouchEvent as ReactTouchEvent } from 'react'; +import { useEffect, useCallback, useRef, useState } from 'react'; +import { EmojiBoard } from '$components/emoji-board'; +import { useSetAtom } from 'jotai'; +import type { MatrixEvent, Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRecentEmoji } from '$hooks/useRecentEmoji'; +import { canEditEvent, getEventEdits } from '$utils/room'; +import { modalAtom, ModalType } from '$state/modal'; +import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; +import { MessageReportItem } from '$components/message/modals/MessageReport'; +import { copyToClipboard } from '$utils/dom'; +import { getMatrixToRoomEvent } from '$plugins/matrix-to'; +import { getViaServers } from '$plugins/via-servers'; +import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks'; +import { createBookmarkItem, computeBookmarkId } from '$features/bookmarks/bookmarkDomain'; +import * as css from './MobileMessageMenu.css'; + +export type MobileMessageMenuProps = { + room: Room; + mEvent: MatrixEvent; + canDelete?: boolean; + canSendReaction?: boolean; + isThreadedMessage?: boolean; + hideReadReceipts?: boolean; + showDeveloperTools?: boolean; + onReplyClick: ( + ev: Parameters>[0], + startThread?: boolean + ) => void; + onEditId?: (eventId?: string) => void; + imagePackRooms: Room[]; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + onClose: () => void; +}; + +function QuickReactions({ + onReaction, + onOpenEmojiBoard, +}: { + onReaction: (key: string, shortcode: string) => void; + onOpenEmojiBoard?: () => void; +}) { + const mx = useMatrixClient(); + const recentEmojis = useRecentEmoji(mx, 5); + + return ( +
+ {recentEmojis.map((emoji) => ( + + ))} + {onOpenEmojiBoard && ( + + )} +
+ ); +} + +type ActionItemProps = { + icon: ReactNode; + label: string; + danger?: boolean; + onClick: () => void; +}; + +function ActionItem({ icon, label, danger, onClick }: ActionItemProps) { + return ( + + ); +} + +function BookmarkActionItem({ + room, + mEvent, + onClose, +}: { + room: Room; + mEvent: MatrixEvent; + onClose: () => void; +}) { + const eventId = mEvent.getId() ?? ''; + const bookmarked = useIsBookmarked(room.roomId, eventId); + const { add, remove } = useBookmarkActions(); + + if (mEvent.isRedacted()) return null; + + return ( + } + label={bookmarked ? 'Remove Bookmark' : 'Bookmark'} + onClick={() => { + if (bookmarked) { + remove(computeBookmarkId(room.roomId, eventId)).catch(() => {}); + } else { + const item = createBookmarkItem(room, mEvent); + if (item) add(item).catch(() => {}); + } + onClose(); + }} + /> + ); +} + +export function MobileMessageMenu({ + room, + mEvent, + canDelete, + canSendReaction, + isThreadedMessage, + hideReadReceipts, + showDeveloperTools, + onReplyClick, + onEditId, + onReactionToggle, + imagePackRooms, + onClose, +}: MobileMessageMenuProps) { + const mx = useMatrixClient(); + const setModal = useSetAtom(modalAtom); + const evtId = mEvent.getId()!; + const evtTimeline = room.getTimelineForEvent(evtId); + const edits = + evtTimeline && + getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); + const isEdited = edits !== undefined; + + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + // Refs for direct DOM manipulation during drag (avoids React re-renders on every frame) + const sheetRef = useRef(null); + const backdropRef = useRef(null); + const dragStartYRef = useRef(null); + + const handleSheetTouchStart = useCallback((e: ReactTouchEvent) => { + if (e.currentTarget.scrollTop === 0) { + dragStartYRef.current = e.touches[0]?.clientY ?? null; + } + }, []); + + const handleSheetTouchMove = useCallback((e: ReactTouchEvent) => { + if (dragStartYRef.current === null) return; + const touch = e.touches[0]; + if (!touch) return; + const deltaY = Math.max(0, touch.clientY - dragStartYRef.current); + if (sheetRef.current) { + sheetRef.current.style.transform = `translateY(${deltaY}px)`; + sheetRef.current.style.transition = 'none'; + } + if (backdropRef.current) { + backdropRef.current.style.opacity = String(Math.max(0, 1 - deltaY / 200)); + } + }, []); + + const handleSheetTouchEnd = useCallback( + (e: ReactTouchEvent) => { + if (dragStartYRef.current === null) return; + const startY = dragStartYRef.current; + dragStartYRef.current = null; + const deltaY = Math.max(0, (e.changedTouches[0]?.clientY ?? startY) - startY); + if (deltaY > 80) { + // Animate out then close + if (sheetRef.current) { + sheetRef.current.style.transform = 'translateY(100%)'; + sheetRef.current.style.transition = 'transform 200ms ease'; + } + if (backdropRef.current) { + backdropRef.current.style.opacity = '0'; + backdropRef.current.style.transition = 'opacity 200ms ease'; + } + setTimeout(onClose, 200); + } else { + // Spring back + if (sheetRef.current) { + sheetRef.current.style.transform = ''; + sheetRef.current.style.transition = 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)'; + } + if (backdropRef.current) { + backdropRef.current.style.opacity = ''; + backdropRef.current.style.transition = ''; + } + } + }, + [onClose] + ); + + // Close on Escape + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + // Prevent body scroll while open + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, []); + + const handleReplyClick = useCallback(() => { + const mockEvent = { + currentTarget: { getAttribute: (attr: string) => (attr === 'data-event-id' ? evtId : null) }, + } as unknown as Parameters>[0]; + onReplyClick(mockEvent); + onClose(); + }, [evtId, onReplyClick, onClose]); + + const handleThreadReplyClick = useCallback(() => { + const mockEvent = { + currentTarget: { getAttribute: (attr: string) => (attr === 'data-event-id' ? evtId : null) }, + } as unknown as Parameters>[0]; + onReplyClick(mockEvent, true); + onClose(); + }, [evtId, onReplyClick, onClose]); + + const handleEditClick = useCallback(() => { + onEditId?.(evtId); + onClose(); + }, [evtId, onEditId, onClose]); + + const stopPropHandler = useCallback((e: React.MouseEvent) => e.stopPropagation(), []); + + const portalContainer = document.getElementById('portalContainer') ?? document.body; + + return createPortal( + <> + {/* Backdrop */} +
e.key === 'Escape' && onClose()} + /> + + {/* Sheet */} +
e.stopPropagation()} + onTouchStart={handleSheetTouchStart} + onTouchMove={handleSheetTouchMove} + onTouchEnd={handleSheetTouchEnd} + > +
+ + {showEmojiPicker ? ( + <> +
+ + + Add Reaction + +
+
+ { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(evtId, mxc, shortcode); + onClose(); + }} + requestClose={() => setShowEmojiPicker(false)} + /> +
+ + ) : ( + <> + {canSendReaction && ( + { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onOpenEmojiBoard={() => setShowEmojiPicker(true)} + /> + )} + + {/* Group 1: Message actions */} +
+ } + label="Reply" + onClick={handleReplyClick} + /> + {!isThreadedMessage && ( + } + label="Reply in Thread" + onClick={handleThreadReplyClick} + /> + )} + {canEditEvent(mx, mEvent) && onEditId && ( + } + label="Edit Message" + onClick={handleEditClick} + /> + )} + } + label="Forward" + onClick={() => { + setModal({ type: ModalType.Forward, room, mEvent }); + onClose(); + }} + /> + {!hideReadReceipts && ( + } + label="Read Receipts" + onClick={() => { + setModal({ type: ModalType.ReadReceipts, room, eventId: evtId }); + onClose(); + }} + /> + )} + {isEdited && ( + } + label="Version History" + onClick={() => { + setModal({ type: ModalType.EditHistory, room, mEvent }); + onClose(); + }} + /> + )} + {showDeveloperTools && ( + } + label="View Source" + onClick={() => { + setModal({ type: ModalType.Source, room, mEvent }); + onClose(); + }} + /> + )} +
+ + {/* Group 2: Utility actions */} +
+ {(() => { + const content = mEvent.getContent(); + const body: string | undefined = content['m.new_content']?.body ?? content.body; + if (!body || mEvent.isRedacted()) return null; + return ( + } + label="Copy Text" + onClick={() => { + copyToClipboard(body); + onClose(); + }} + /> + ); + })()} + {mEvent.getId() && ( + } + label="Copy Link" + onClick={() => { + copyToClipboard( + getMatrixToRoomEvent(room.roomId, mEvent.getId()!, getViaServers(room)) + ); + onClose(); + }} + /> + )} + +
+ + {/* Group 3: Destructive actions */} + {(!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId() ? ( +
+ {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} +
+ ) : null} + + )} +
+ , + portalContainer + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..345924eb1 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -33,7 +33,7 @@ import type { DateFormat, MessageSpacing, CaptionPosition } from '$state/setting import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings'; import { SettingTile } from '$components/setting-tile'; import { KeySymbol } from '$utils/key-symbol'; -import { isMacOS, mobileOrTablet } from '$utils/user-agent'; +import { isMacOS, isPhone, mobileOrTablet } from '$utils/user-agent'; import { stopPropagation } from '$utils/keyboard'; import { useMessageLayoutItems } from '$hooks/useMessageLayout'; import { useCaptionPositionItems } from '$hooks/useCaptionPosition'; @@ -1436,7 +1436,7 @@ export function General({ requestBack, requestClose }: Readonly) { - + diff --git a/src/app/hooks/ios-keyboard-fix/device.ts b/src/app/hooks/ios-keyboard-fix/device.ts new file mode 100644 index 000000000..7b7bb06fc --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/device.ts @@ -0,0 +1,24 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. + +export const isStandalonePWA = (): boolean => { + if (typeof window === 'undefined') return false; + // iOS Safari uses navigator.standalone (legacy, non-standard). + // Other browsers use the W3C display-mode media query. + const iosStandalone = + 'standalone' in window.navigator && + (window.navigator as Navigator & { standalone?: boolean }).standalone === true; + const displayModeStandalone = window.matchMedia('(display-mode: standalone)').matches; + return iosStandalone || displayModeStandalone; +}; + +export const isTablet = (): boolean => typeof window !== 'undefined' && window.innerWidth >= 768; + +export const needsVirtualKeyboard = (): boolean => { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + const hasTouchScreen = navigator.maxTouchPoints > 0; + const isCoarsePointer = window.matchMedia('(pointer: coarse)').matches; + return hasTouchScreen && isCoarsePointer; +}; diff --git a/src/app/hooks/ios-keyboard-fix/index.ts b/src/app/hooks/ios-keyboard-fix/index.ts new file mode 100644 index 000000000..4918c970a --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/index.ts @@ -0,0 +1,5 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +export { isStandalonePWA, isTablet, needsVirtualKeyboard } from './device'; +export { useKeyboardHeight } from './useKeyboardHeight'; +export { useScrollLock } from './useScrollLock'; diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts new file mode 100644 index 000000000..233c01de0 --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -0,0 +1,151 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +import { useEffect, useRef, useState } from 'react'; + +// Measures iOS keyboard height via the Visual Viewport API and synchronously +// manages the --sable-visible-height / --sable-safe-bottom CSS custom properties +// that control #root layout height. +// +// CSS variables are set/cleared directly inside the event handler (no React +// useEffect) so there is no frame gap between "keyboard closed" being detected +// and the layout reverting to full height. This eliminates the race condition +// where a follow-on viewport.resize event would re-set the variable after the +// React async effect had already removed it, causing a persistent bottom gap. +// +// Stability filter — only commits React state (isKeyboardVisible, keyboardHeight) +// once iOS reports the same viewport height for STABILITY_MS ms. iOS emits +// chaotic transient values during keyboard transitions (text ↔ emoji), so the +// filter prevents those from triggering unnecessary re-renders. +// +// triggerPreLift: called from onMouseDown so Safari sees the textarea as already +// visible and skips its document-scroll prediction. +const STABILITY_MS = 80; + +export function useKeyboardHeight() { + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + // Mirror state in refs so triggerPreLift sees fresh values from + // an onMouseDown handler without re-creating the function each render. + const savedHeight = useRef(0); + const hasOpenedOnce = useRef(false); + const isVisibleRef = useRef(false); + + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return undefined; + + let baselineHeight = window.innerHeight; + let stabilityTimer: ReturnType | null = null; + let pendingValue = 0; + // Tracks whether --sable-visible-height is currently set so the opening + // path only fires setCSSVars once (avoids double-setting on repeated + // resize events while the keyboard is already open). + let cssVarsSet = false; + + const setCSSVars = (viewportHeight: number) => { + document.documentElement.style.setProperty( + '--sable-visible-height', + `${Math.round(viewportHeight)}px` + ); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + cssVarsSet = true; + }; + + const clearCSSVars = () => { + document.documentElement.style.removeProperty('--sable-visible-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); + cssVarsSet = false; + }; + + const handleResize = () => { + const calculatedHeight = baselineHeight - viewport.height; + + // Keyboard closing — act immediately, no stability wait. + // clearCSSVars() runs synchronously here, before React schedules any + // re-render, so there is no window in which a follow-on resize event + // can observe the variable as missing and incorrectly re-set it. + if (calculatedHeight < 30) { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + clearCSSVars(); + setKeyboardHeight(0); + setIsKeyboardVisible(false); + isVisibleRef.current = false; + return; + } + + // Keyboard opening / open. + // On the very first resize that signals a keyboard, immediately shrink + // the layout (before the stability gate) so the input bar rises before + // iOS applies its own scroll-prediction pass. + if (!cssVarsSet) { + setCSSVars(viewport.height); + } + + // Cancel any document scroll iOS may have applied as scroll-prediction. + if (window.scrollY !== 0) { + window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); + } + + // Wait for the height to settle. Each resize within STABILITY_MS + // restarts the timer, so transient mid-transition readings never + // commit — only the final settled value does. + pendingValue = calculatedHeight; + if (stabilityTimer) clearTimeout(stabilityTimer); + stabilityTimer = setTimeout(() => { + savedHeight.current = pendingValue; + hasOpenedOnce.current = true; + isVisibleRef.current = true; + setCSSVars(viewport.height); // refine to final settled viewport height + setKeyboardHeight(pendingValue); + setIsKeyboardVisible(true); + }, STABILITY_MS); + }; + + // Orientation change resets everything — keyboard heights measured + // in portrait don't apply in landscape and vice versa. Drop saved + // state and start fresh; the next focus will re-measure. + const handleOrientationChange = () => { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + pendingValue = 0; + savedHeight.current = 0; + hasOpenedOnce.current = false; + isVisibleRef.current = false; + clearCSSVars(); + setKeyboardHeight(0); + setIsKeyboardVisible(false); + // Re-baseline after iOS settles the new layout. + setTimeout(() => { + baselineHeight = window.innerHeight; + }, 200); + }; + + viewport.addEventListener('resize', handleResize); + window.addEventListener('orientationchange', handleOrientationChange); + return () => { + if (stabilityTimer) clearTimeout(stabilityTimer); + viewport.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleOrientationChange); + clearCSSVars(); + }; + }, []); + + // Pre-lift: called from onMouseDown, BEFORE focus event fires. + // Only acts if the keyboard is currently open — otherwise a button + // tap would lift the bar with no keyboard behind it. + // Reads from refs so it always sees the latest state, even when + // captured by an onMouseDown handler that mounted earlier. + const triggerPreLift = () => { + if (hasOpenedOnce.current && savedHeight.current > 0 && isVisibleRef.current) { + setKeyboardHeight(savedHeight.current); + } + }; + + return { keyboardHeight, isKeyboardVisible, triggerPreLift }; +} diff --git a/src/app/hooks/ios-keyboard-fix/useScrollLock.ts b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts new file mode 100644 index 000000000..d4c07c6e0 --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts @@ -0,0 +1,37 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +import { useEffect } from 'react'; + +// Conditional scroll-lock safety net for the input bar. +// While the keyboard is open, Safari can still trigger a document scroll +// when the keyboard mode switches (text ↔ emoji), because there is no +// onMouseDown moment for us to pre-lift on. This listener detects the +// document moving away from scrollY: 0 and snaps it back, so the input +// bar does not drift with the page. +// Outside of this state the page scrolls normally. +// +// Important: this hook assumes the layout where window.scrollY stays at 0 +// because the page itself does not scroll — content scrolls inside
+// with overflow-y:auto, while html/body/#root use overflow:hidden. See +// README "Layout structure" and docs/ARCHITECTURE.md. Without that layout, +// this lock will fight legitimate page scroll while the keyboard is open. +export function useScrollLock(active: boolean) { + useEffect(() => { + // Snap back immediately in case iOS scroll prediction ran during the + // stability window, before this lock became active. + if (active && window.scrollY > 0) { + window.scrollTo(0, 0); + } + + const preventScroll = () => { + if (active && window.scrollY > 0) { + window.scrollTo(0, 0); + } + }; + + window.addEventListener('scroll', preventScroll); + return () => { + window.removeEventListener('scroll', preventScroll); + }; + }, [active]); +} diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index a03342fc5..ca73fbb7d 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -7,7 +7,14 @@ import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; import { useMatrixClient } from './useMatrixClient'; import { getCanonicalAliasOrRoomId } from '../utils/matrix'; -import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../pages/pathUtils'; +import { + getDirectRoomPath, + getHomeRoomPath, + getSpaceRoomPath, + getDirectPath, + getHomePath, + getSpacePath, +} from '../pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; @@ -56,9 +63,13 @@ export function NotificationJumper() { jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. + // First replace the current history entry with the section overview so that + // pressing back (including native iOS swipe-back) returns to the section list + // rather than the room the user was in before the notification. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectPath(), { replace: true }); + navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -70,15 +81,12 @@ export function NotificationJumper() { if (orphanParents.length > 0) { const parentSpace = guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0]; - navigate( - getSpaceRoomPath( - getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId), - roomIdOrAlias, - pending.eventId - ) - ); + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId); + navigate(getSpacePath(spaceIdOrAlias), { replace: true }); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias, targetEventId)); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomePath(), { replace: true }); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b089cf8f8..423c19c83 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -62,6 +62,7 @@ import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +import { useSyncState } from '$hooks/useSyncState'; const pushRelayLog = createDebugLogger('push-relay'); @@ -688,6 +689,36 @@ function SyncNotificationSettingsWithServiceWorker() { return null; } +/** + * Tells the service worker whether the Matrix sync connection is healthy. + * When sync is in Reconnecting or Error state the in-app notification path is + * broken, so the SW must not suppress OS push notifications even while the app + * is visible. + */ +function SyncStateWithServiceWorker() { + const mx = useMatrixClient(); + + const postSyncHealth = useCallback((healthy: boolean) => { + if (!('serviceWorker' in navigator)) return; + const msg = { type: 'setSyncState', healthy }; + navigator.serviceWorker.controller?.postMessage(msg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + }, []); + + useSyncState( + mx, + useCallback( + (current) => { + const healthy = current !== SyncState.Reconnecting && current !== SyncState.Error; + postSyncHealth(healthy); + }, + [postSyncHealth] + ) + ); + + return null; +} + function SlidingSyncActiveRoomSubscriber() { useSlidingSyncActiveRoom(); return null; @@ -874,6 +905,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 70ba9221e..0b8261d82 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -90,7 +90,7 @@ function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) { (); + // Aggregate unread across all recursive child rooms (space rooms themselves + // carry no messages, so RoomUnreadProvider would always return nothing). + const roomToParents = useAtomValue(roomToParentsAtom); + const allChild = useSpaceChildren( + allRoomsAtom, + space.roomId, + useRecursiveChildScopeFactory(mx, roomToParents) + ); + const unread = useRoomsUnread(allChild, roomToUnreadAtom); + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); const cords = evt.currentTarget.getBoundingClientRect(); @@ -421,74 +431,70 @@ function SpaceTab({ }; return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0} - count={unread.highlight > 0 ? unread.highlight : unread.total} - /> - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } + + + {(triggerRef) => ( + + ( + {nameInitials(space.name, 2)} + )} /> - )} - + + )} + + {unread && ( + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + onUnpin={onUnpin} + /> + + } + /> )} - + ); } diff --git a/src/app/styles/overrides/General.css.ts b/src/app/styles/overrides/General.css.ts index 8dee3b14c..f0fa49d80 100644 --- a/src/app/styles/overrides/General.css.ts +++ b/src/app/styles/overrides/General.css.ts @@ -1,4 +1,13 @@ import { globalStyle } from '@vanilla-extract/css'; +import { color } from 'folds'; + +// Ensure the safe-area padding areas on #root (top/bottom on iOS) show +// the app's background container color instead of the white body fallback. +// Without this, the 34px home-indicator gap at the bottom is visibly white +// against the gray content, making it look like a UI gap on iOS PWA. +globalStyle('#root', { + backgroundColor: color.Background.Container, +}); globalStyle( ` diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index e947d9746..abe9fc078 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -64,18 +64,45 @@ export const selectFile = ( if (accept) input.accept = accept; if (multiple) input.multiple = true; + // iOS Safari requires the input to be in the DOM to reliably trigger the + // file picker dialog; remove it immediately after selection. + input.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; + document.body.appendChild(input); + + let settled = false; + + const cleanup = () => { + input.removeEventListener('change', changeHandler); + input.removeEventListener('cancel', cancelHandler); + if (input.parentNode) input.parentNode.removeChild(input); + }; + + const settle = (value: FilesOrFile | undefined) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const changeHandler = () => { const fileList = input.files; - if (!fileList) { - resolve(undefined); - } else { - const files: File[] = getFilesFromFileList(fileList); - resolve((multiple ? files : files[0]) as FilesOrFile); + // On iOS Safari, `change` can fire with an empty FileList (e.g. the + // picker was dismissed, or the file isn't available yet). Treat this + // the same as a cancellation so callers receive `undefined` rather than + // an empty array that silently produces no upload items. + if (!fileList || fileList.length === 0) { + settle(undefined); + return; } - input.removeEventListener('change', changeHandler); + const files: File[] = getFilesFromFileList(fileList); + settle(files.length === 0 ? undefined : ((multiple ? files : files[0]) as FilesOrFile)); }; + // iOS 15+: fires when the picker is dismissed without a file selection. + const cancelHandler = () => settle(undefined); + input.addEventListener('change', changeHandler); + input.addEventListener('cancel', cancelHandler); input.click(); }); diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index e1a2e0a61..61b5f217e 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -9,6 +9,11 @@ const isMobileOrTablet = (() => { return false; })(); +// True only for phone-form-factor devices (not tablets). +// ua-parser-js sets device.type === 'mobile' for phones and 'tablet' for tablets, +// so this correctly returns false for iPads with external keyboards. +const isPhoneDevice = result.device.type === 'mobile'; + const normalizeMacName = (os?: string) => { if (!os) return os; if (os === 'Mac OS') return 'macOS'; @@ -20,6 +25,8 @@ const isMac = result.os.name === 'Mac OS'; export const ua = () => result; export const isMacOS = () => isMac; export const mobileOrTablet = () => isMobileOrTablet; +/** True only for phones; returns false for tablets (e.g. iPad with external keyboard). */ +export const isPhone = () => isPhoneDevice; export const deviceDisplayName = (): string => { const browser = result.browser.name; diff --git a/src/index.css b/src/index.css index 9550a370b..36eb5ff40 100755 --- a/src/index.css +++ b/src/index.css @@ -54,12 +54,19 @@ body { font-family: var(--font-secondary); font-size: 16px; font-weight: 400; + /* Prevent iOS Safari rubber-band overscroll from shifting the entire app off-screen. */ + overscroll-behavior: none; /* eslint-disable-next-line css/no-invalid-properties */ background-color: var(--sable-bg-container); } #root { + position: relative; width: 100%; - height: 100%; + /* On iOS PWA, --sable-visible-height is set (via RoomInput's useEffect) + when the keyboard is open, shrinking the root to the visual viewport + so the whole layout reflows correctly above the keyboard. Falls back + to 100% when no keyboard is present. */ + height: var(--sable-visible-height, 100%); display: flex; flex-direction: column; } diff --git a/src/sw.ts b/src/sw.ts index 222156e72..5af45aa23 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -12,6 +12,10 @@ let notificationSoundEnabled = true; // The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, // so we use this explicit flag as a fallback. let appIsVisible = false; +// Tracks whether the Matrix sync connection is healthy. +// Defaults to true; set false when the app reports Reconnecting/Error so that +// OS push notifications are not suppressed while the in-app path is broken. +let syncIsHealthy = true; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -584,6 +588,11 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { appIsVisible = (data as { visible: boolean }).visible; } } + if (type === 'setSyncState') { + if (typeof (data as { healthy?: unknown }).healthy === 'boolean') { + syncIsHealthy = (data as { healthy: boolean }).healthy; + } + } if (type === 'setNotificationSettings') { if ( typeof (data as { notificationSoundEnabled?: unknown }).notificationSoundEnabled === 'boolean' @@ -763,19 +772,36 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. - // Combine clients.matchAll() visibility with the explicit appIsVisible flag - // because iOS Safari PWA often returns empty or stale results from matchAll(). + // + // Require BOTH the explicit appIsVisible flag AND a visible client from + // matchAll() before suppressing. appIsVisible resets to false every time the + // SW starts fresh; on iOS the browser kills the SW between pushes, so on the + // next push appIsVisible is always false — we never suppress on a cold SW + // restart, which prevents the "notifications stop after a while" bug where + // stale matchAll() data (visibilityState stuck at 'visible') would cause all + // subsequent notifications to be silently dropped. + // + // Also require syncIsHealthy: if the Matrix sync is in Reconnecting/Error + // state, the in-app notification path is broken, so we must show the OS + // notification even when the app is visible. + // + // When matchAll() returns zero clients (iOS Safari PWA fully-suspended quirk), + // clients.some() returns false — do NOT suppress. Better to show a duplicate + // (handled gracefully by the in-app banner) than to silently drop a + // notification while the app is backgrounded. const hasVisibleClient = - appIsVisible || clients.some((client) => client.visibilityState === 'visible'); + appIsVisible && syncIsHealthy && clients.some((client) => client.visibilityState === 'visible'); console.debug( '[SW push] appIsVisible:', appIsVisible, + '| syncIsHealthy:', + syncIsHealthy, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); console.debug('[SW push] hasVisibleClient:', hasVisibleClient); if (hasVisibleClient) { - console.debug('[SW push] suppressing OS notification — app is visible'); + console.debug('[SW push] suppressing OS notification — app is visible and sync is healthy'); return; }