Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9b15df6
fix(mobile): improve mobile UX across swipe, menus, input, and keyboard
Just-Insane May 12, 2026
c8cc095
fix(mobile): iOS file picker selection not propagating to upload board
Just-Insane May 12, 2026
19ccb8f
feat(messages): replace mobile long-press bar with Discord-style bott…
Just-Insane May 12, 2026
c024d1b
fix(mobile): darken long-press backdrop; clear text selection on fire
Just-Insane May 12, 2026
6606cb8
fix(emoji-picker): clamp position so picker stays on screen on narrow…
Just-Insane May 12, 2026
e021fcb
fix(pwa): add viewport-fit=cover; prevent iOS rubber-band overscroll
Just-Insane May 12, 2026
a928df3
fix(mobile): safe-area top padding; opaque backdrop; prevent text sel…
Just-Insane May 12, 2026
e12238b
fix(editor): iOS autocapitalize for empty Slate editor on focus
Just-Insane May 12, 2026
aaa5c08
fix(mobile): revert dvh layout; remove context menu dark overlay
Just-Insane May 12, 2026
070c2dc
fix(mobile): use real theme tokens in MobileMessageMenu; Discord-styl…
Just-Insane May 12, 2026
14aa60d
fix(layout): restore safe-area padding on #root for notch and home in…
Just-Insane May 12, 2026
78d4057
fix(mobile): add missing menu items; resize root to visual viewport o…
Just-Insane May 12, 2026
f238299
fix(mobile): threshold-based visual viewport height with keyboard-ope…
Just-Insane May 12, 2026
999ca07
fix(mobile): revert viewport height hack, add ios-pwa-keyboard-fix
Just-Insane May 12, 2026
85bc002
fix(mobile): use CSS var layout for keyboard, reset scroll on open
Just-Insane May 12, 2026
fc50b17
fix(mobile): fill safe-area gaps with Background.Container color
Just-Insane May 12, 2026
be0f268
fix(mobile): remove #root padding-bottom, scope safe-area to room inp…
Just-Insane May 12, 2026
5f9ac2f
fix(mobile): forward appearance, keyboard jank, bookmark data integrity
Just-Insane May 12, 2026
e144ee3
fix(mobile): safe area for three-dots button and bottom gap
Just-Insane May 12, 2026
b878411
fix(mobile): eliminate keyboard jank and clean up room footer
Just-Insane May 12, 2026
86f8609
fix(pwa): use 100dvh on html to fill full display on iOS
Just-Insane May 13, 2026
f7fdb3a
fix(sidebar): show unread notification dots on space tabs
Just-Insane May 12, 2026
84846af
fix(mobile): consolidate keyboard CSS vars into hook, fix scroll jank…
Just-Insane May 13, 2026
d6aa0d0
fix(mobile): fix banner position on keyboard close, iOS autocapitaliz…
Just-Insane May 13, 2026
d8f1157
fix(mobile): suppress post-close iOS viewport bounce from re-setting …
Just-Insane May 13, 2026
ec58f46
Revert "fix(mobile): suppress post-close iOS viewport bounce from re-…
Just-Insane May 13, 2026
8ceec5c
fix(pwa): extend ICB workaround to body to prevent bottom gap on iOS
Just-Insane May 13, 2026
69c7335
fix(pwa): revert viewport-fit=cover and ICB height workarounds
Just-Insane May 13, 2026
03ba3c1
feat(mobile): swipe-down to dismiss long-press menu, add emoji reacti…
Just-Insane May 13, 2026
fa08330
feat(mobile): visual swipe feedback and embedded emoji picker in long…
Just-Insane May 13, 2026
fa7169b
fix(thread): prevent keyboard flicker on mobile during reply\n\nhandl…
Just-Insane May 13, 2026
36065f3
fix(editor): stop placeholder flicker loop on reply focus
Just-Insane May 13, 2026
1987879
fix(settings): enable Enter-to-send toggle for tablets with external …
Just-Insane May 13, 2026
9776653
fix(layout): restore minHeight on RoomViewFollowing to prevent input …
Just-Insane May 14, 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
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
27 changes: 26 additions & 1 deletion src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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';
Expand Down Expand Up @@ -440,10 +440,35 @@
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;

Check failure on line 463 in src/app/components/editor/Editor.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'justRestoredFocusRef'.
requestAnimationFrame(() => {
if (!ReactEditor.isFocused(editor) || Node.string(editor).length > 0) return;
Transforms.insertText(editor, ' ');
requestAnimationFrame(() => {
Transforms.delete(editor, { reverse: true });
});
});
}}
/>
</Scroll>
{(hasAfter || showResponsiveAfterInline) && (
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
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/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))`,
});
Loading
Loading