Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
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))`,
});
Loading
Loading