Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8ac8175
docs: document mobile iOS keyboard issue
Just-Insane May 18, 2026
840b1b5
fix(layout): iPad/tablet detection, desktop website mode, and mobileO…
Just-Insane May 13, 2026
934f378
fix(keyboard): share height state across useKeyboardHeight instances
Just-Insane May 13, 2026
bf92502
fix(mobile): improve mobile UX across swipe, menus, input, and keyboard
Just-Insane May 12, 2026
6c72c43
feat(messages): replace mobile long-press bar with Discord-style bott…
Just-Insane May 12, 2026
3e3d8d5
fix(mobile): safe-area, PWA viewport, keyboard CSS layout, and menu t…
Just-Insane May 12, 2026
4720c30
fix(mobile): safe area gaps, keyboard jank, ICB workarounds, and bann…
Just-Insane May 12, 2026
7f44bb0
feat(mobile): swipe-down to dismiss long-press menu, add emoji reacti…
Just-Insane May 13, 2026
ce17397
fix(mobile): thread keyboard flicker, timeline scroll, and layout fixes
Just-Insane May 13, 2026
be67880
fix(notifications): prevent push dropout after SW restart
Just-Insane May 8, 2026
daf581f
fix(mobile): autocap, RTL alignment, splash screen, and SW notificati…
Just-Insane May 8, 2026
2e67b72
fix(sync): resync sliding sync on network resume and app foreground
Just-Insane May 15, 2026
3ae0977
fix(sync): add delayed fallback retries on mobile after app foregroun…
Just-Insane May 15, 2026
323cb77
fix(sync): show Connecting banner when fast-path clears splash early
Just-Insane May 18, 2026
17d1e1f
fix(mobile): use mobileOrTabletLayout for members drawer so iPad gets…
Just-Insane May 18, 2026
c8cc80b
fix(mobile): show unread indicator after timeline reset on sync resume
Just-Insane May 18, 2026
4107611
fix(editor): add tabbableOptions displayCheck none for iPad autocompl…
Just-Insane May 19, 2026
535dc09
feat(mobile): add missing items to mobile long-press message menu
Just-Insane May 19, 2026
e892c08
feat(mobile): add bookmark toggle and fix keyboard cover in message menu
Just-Insane May 19, 2026
e800c84
fix(mobile): match kick button style to delete/report; fix nickname f…
Just-Insane May 19, 2026
0db1f0d
chore: add changeset
Just-Insane May 19, 2026
4ab861c
fix(mobile): address Copilot review feedback
Just-Insane May 19, 2026
8ed1d16
fix(mobile/ios): keep service worker alive to prevent full reload on …
Just-Insane May 20, 2026
c763234
Add pull-to-refresh
Just-Insane May 20, 2026
bc17f93
fix(timeline): sort events by origin_server_ts to fix ordering on mob…
Just-Insane May 20, 2026
a625972
fix(mobile): rebuild timelines after pull-to-refresh
Just-Insane May 20, 2026
f320c6c
fix(mobile): guard localStorage writes against QuotaExceededError; re…
Just-Insane May 20, 2026
e6a7064
feat(mobile): add pull-to-refresh visual indicator
Just-Insane May 20, 2026
60d5d39
fix(mobile): remove bookmarks integration not available on this branch
Just-Insane May 20, 2026
e9456d2
fix(mobile): show update banner instead of silent SW reload
Just-Insane May 20, 2026
629ff2d
fix(timeline): force re-subscription on pull-to-refresh to fix ordering
Just-Insane May 20, 2026
dbbc9c1
fix(timeline): immediately reset timelines in scheduleForceReset
Just-Insane May 20, 2026
69e8b36
fix(ios-pwa): prevent bfcache eviction and spurious reloads
Just-Insane May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mobile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix iOS sync resume, network latency retries, iPad layout, and connecting banner during fast-path sync. Add missing items to mobile long-press message menu; fix bookmark toggle and iOS keyboard cover.
35 changes: 35 additions & 0 deletions docs/MOBILE_FIXES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Mobile UX Fixes

This document tracks mobile-specific issues that need to be addressed in `feat/mobile`.

## Issue #9: iOS keyboard show/hide triggers jump button

**Problem**: Sometimes when opening/closing the keyboard on iOS, the jump to present button is displayed incorrectly.

**Root Cause**:

- iOS viewport height changes when keyboard appears/disappears
- Virtual keyboard causes viewport resize events
- Timeline scroll position calculation doesn't account for keyboard state
- Jump button visibility logic triggers on viewport changes

**Proposed Fix**:

- Detect iOS virtual keyboard state changes
- Exclude keyboard-triggered viewport changes from jump button logic
- Use `visualViewport` API instead of window.innerHeight on iOS
- Debounce jump button visibility checks during keyboard transitions
- Store keyboard state and ignore scroll position during keyboard animation

**Implementation Notes**:

- Use `window.visualViewport.height` vs `window.innerHeight` to detect keyboard
- Listen to `visualViewport` resize events
- Add keyboard state to timeline context
- Filter out scroll events during keyboard animation (~300ms)

**Related Files**:

- Timeline scroll handling
- Jump to present button logic
- iOS-specific viewport handling
4 changes: 2 additions & 2 deletions src/app/components/SwipeableChatWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
Expand Down
4 changes: 2 additions & 2 deletions src/app/components/SwipeableMessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/components/SwipeableOverlayWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/editor/Editor.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export const EditorTextarea = style([
{
flexGrow: 1,
height: 'auto',
// Detect text direction per-keystroke so RTL text right-aligns automatically.
// Matches the unicodeBidi: 'plaintext' pattern used by MessageTextBody.
unicodeBidi: 'plaintext',
padding: `${toRem(13)} 0 0`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
Expand Down
55 changes: 51 additions & 4 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Node, createEditor } from 'slate';
import type { RenderLeafProps, RenderElementProps, RenderPlaceholderProps } from 'slate-react';
import { Slate, Editable, withReact, ReactEditor } from 'slate-react';
import { withHistory } from 'slate-history';
import { mobileOrTablet } from '$utils/user-agent';
import { isPhone, mobileOrTablet } from '$utils/user-agent';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import type { CustomElement } from './slate';
Expand Down Expand Up @@ -114,6 +114,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const singleLineWidthOffsetRef = useRef(0);
const latestValueRef = useRef<Descendant[]>(editor.children);
const isMultilineRef = useRef(false);
// Tracks whether a triggerAutoCapitalize rAF is already queued to avoid stacking
// multiple rAFs when content changes fire rapidly (e.g. IME composition).
const autocapPendingRef = useRef(false);
const [isMultiline, setIsMultiline] = useState(false);
const [measurementVersion, setMeasurementVersion] = useState(0);
const hasBefore = Boolean(before);
Expand Down Expand Up @@ -348,8 +351,29 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
updateMultilineLayout(latestValueRef.current);
}, [measurementVersion, updateMultilineLayout]);

// Mobile OSes (iOS and Android) do not reliably capitalise the first letter in an empty
// contenteditable. Both platforms render a zero-width placeholder character (\uFEFF)
// inside the Slate DOM node to maintain the cursor, and their keyboards interpret this
// as existing content — so they don't apply sentence-case to the next keystroke.
// Toggling the autocapitalize attribute from 'none' → 'sentences' on the focused
// contenteditable forces the keyboard to re-evaluate capitalisation state with no
// content changes, no focus shifts, and no keyboard dismissal.
const triggerAutoCapitalize = useCallback(() => {
if (!mobileOrTablet()) return;
if (autocapPendingRef.current) return;
const el = editableRef.current;
if (!el) return;
autocapPendingRef.current = true;
el.setAttribute('autocapitalize', 'none');
requestAnimationFrame(() => {
el.setAttribute('autocapitalize', 'sentences');
autocapPendingRef.current = false;
});
}, []);

const handleChange = useCallback(
(value: Descendant[]) => {
const prevText = latestValueRef.current.map((node) => Node.string(node)).join('');
latestValueRef.current = value;
measurementCacheRef.current = null;
if (multilineMeasureFrameRef.current !== null) {
Expand All @@ -358,8 +382,15 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
}
setMeasurementVersion((version) => version + 1);
onChange?.(value);
// After a send, content goes from non-empty to empty while the editor stays focused.
// Trigger the autocap attribute toggle so the next message starts capitalised.
// onBlur keeps focus on the editor so isFocused() is true when this fires.
const nextText = value.map((node) => Node.string(node)).join('');
if (prevText.length > 0 && nextText.length === 0 && ReactEditor.isFocused(editor)) {
triggerAutoCapitalize();
}
},
[onChange]
[onChange, editor, triggerAutoCapitalize]
);

const renderElement = useCallback(
Expand All @@ -371,8 +402,10 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(

const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
// mobile ignores config option
if (mobileOrTablet() && evt.key === 'Enter' && !evt.shiftKey) {
// Phones (on-screen keyboard) ignore the enter-to-send config option.
// Tablets with an external keyboard should still forward Enter to onKeyDown
// so RoomInput can honour the enterForNewline / mod+enter settings.
if (isPhone() && evt.key === 'Enter' && !evt.shiftKey) {
return;
}

Expand Down Expand Up @@ -440,6 +473,20 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
onPaste={onPaste}
// Defer to OS capitalization setting (respects iOS sentence-case toggle).
autoCapitalize="sentences"
// Detect text direction per-message so RTL languages (Arabic, Hebrew, etc.)
// automatically right-align without any toggle.
dir="auto"
// Trigger autocap re-evaluation when the editor gains focus empty.
// This handles the initial tap-to-focus case: Slate's DOM contains a
// \uFEFF placeholder that the keyboard sees as existing content and so
// skips sentence-case. The attribute toggle forces a re-evaluation.
// autocapPendingRef prevents double-fire if handleChange also fires
// (e.g. the send clears content while focus is transferred).
onFocus={() => {
if (mobileOrTablet() && Node.string(editor).length === 0) {
triggerAutoCapitalize();
}
}}
// keeps focus after pressing send.
onBlur={() => {
if (mobileOrTablet()) ReactEditor.focus(editor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function AutocompleteMenu({
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
escapeDeactivates: stopPropagation,
tabbableOptions: { displayCheck: 'none' },
}}
>
<Menu
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -393,6 +395,7 @@ export function EmojiBoard({
imagePackRooms,
requestClose,
returnFocusOnDeactivate,
active = true,
onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect,
Expand Down Expand Up @@ -534,6 +537,7 @@ export function EmojiBoard({

return (
<FocusTrap
active={active}
focusTrapOptions={{
returnFocusOnDeactivate,
initialFocus: false,
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/message/layout/layout.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ export const MessageTextBody = recipe({
base: {
unicodeBidi: 'plaintext',
alignSelf: 'start',
// Full width ensures RTL text (direction:rtl from dir=auto) has room to right-align
// within the flex column that contains the message body.
width: '100%',
wordBreak: 'break-word',
fontSize: '1rem !important', // Override folds Text component to enable page zoom scaling
},
Expand Down
38 changes: 1 addition & 37 deletions src/app/components/notification-banner/NotificationBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,45 +177,9 @@ export function NotificationBanner() {
// We store an array locally so multiple rapid notifications stack briefly.
const [banner, setBanner] = useAtom(inAppBannerAtom);
const [queue, setQueue] = useState<InAppBannerNotification[]>([]);
const containerRef = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -247,7 +211,7 @@ export function NotificationBanner() {

log.log('[Banner] Rendering', queue.length, 'banners');
return (
<div ref={containerRef} className={css.BannerContainer} aria-live="polite" aria-atomic="false">
<div className={css.BannerContainer} aria-live="polite" aria-atomic="false">
{queue.map((n) => (
<BannerItem key={n.id} notification={n} onDismiss={handleDismiss} />
))}
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/page/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Box, Header, Line, Scroll, Text, as } from 'folds';
import classNames from 'classnames';
import { ContainerColor } from '$styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { mobileOrTabletLayout } from '$utils/user-agent';
import * as css from './style.css';

type PageRootProps = {
Expand All @@ -16,7 +17,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
{screenSize !== ScreenSize.Mobile && (
{screenSize !== ScreenSize.Mobile && !mobileOrTabletLayout() && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{children}
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/page/style.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 7 additions & 2 deletions src/app/components/splash-screen/SplashScreen.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))`,
});
7 changes: 4 additions & 3 deletions src/app/features/room/MembersDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { formatCompactNumber } from '$utils/formatCompactNumber';
import * as css from './MembersDrawer.css';
import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer';
import { mobileOrTabletLayout } from '$utils/user-agent';
import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize';

type MemberDrawerHeaderProps = {
Expand Down Expand Up @@ -316,7 +317,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
}, [memberSidebarWidth]);

const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const isMobile = mobileOrTabletLayout() || screenSize === ScreenSize.Mobile;
const hideText = curWidth <= 80 && !isMobile;
return (
<Box
Expand All @@ -325,12 +326,12 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
direction="Column"
style={{
position: 'relative',
width: isMobile ? '100%' : toRem(curWidth),
width: !mobileOrTabletLayout() ? toRem(curWidth) : '100%',
}}
>
<MemberDrawerHeader room={room} hideText={hideText} />
<Box className={css.MemberDrawerContentBase} grow="Yes">
{!isMobile && (
{!mobileOrTabletLayout() && (
<SidebarResizer
setCurWidth={setCurWidth}
sidebarWidth={memberSidebarWidth}
Expand Down
Loading
Loading