Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/fix-mobile-send-jump-freeze-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix mobile message sending, preserve jump-to-message context during timeline refreshes, and add PWA freeze diagnostics for service worker recovery.
85 changes: 85 additions & 0 deletions docs/MANUAL_QA_ISSUES_103_109_111.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Manual QA Checklist

Manual checks for the current Charm follow-up work on `#111`, `#103`, and `#109`.

## Issue #111: Mobile send button UX

Environment:

- iPhone-sized Safari layout
- Safari standalone PWA if available
- one room with delayed events enabled
- one room or account state where delayed events are unavailable

Steps:

1. Open a room and focus the composer so the keyboard is visible.
2. Type a short message and tap the main send button.
3. Confirm the message sends immediately while the keyboard is still open.
4. Type another message and tap the separate schedule button.
5. Close the schedule dialog without submitting.
6. Tap the main send button again.
7. Confirm the message sends immediately and the composer is not stuck.
8. Re-open the schedule dialog, choose a future time, and submit it.
9. Confirm the primary button reflects scheduled-send state and no immediate send occurs.
10. In a context where delayed events are unavailable, confirm there is no separate schedule button and normal sending still works.

Expected results:

- main send never depends on long-press
- schedule action is explicit and separate on mobile
- closing schedule UI does not break later sends

## Issue #103: Jump buttons broken

Environment:

- a room with enough history that the target event is not already in the loaded viewport
- at least one reply link, bookmark, or permalink target

Steps:

1. Open a reply link or permalink to an older message.
2. Confirm the target message appears before any snap back to the latest timeline.
3. Scroll upward from the jumped position.
4. Scroll downward from the jumped position.
5. Repeat using a bookmark jump if available.
6. Background the app briefly, return to foreground, and confirm the same jumped context remains stable.

Expected results:

- jump lands on the requested target
- timeline does not immediately snap to bottom
- surrounding scroll does not jitter or reset unexpectedly
- foreground return does not discard the event-targeted context too early

## Issue #109: PWA freeze instrumentation

Environment:

- Safari desktop PWA preferred
- Sentry and browser console available if possible

Steps:

1. Launch the PWA and leave it idle for an extended period.
2. Return and attempt basic interaction:
- switch rooms
- open a DM
- click the composer
- try a normal browser reload if the UI still responds
3. If the app freezes, note:
- whether the window still repaints
- whether clicks are ignored everywhere
- whether reload is possible
4. Capture any visible console output and the latest Sentry breadcrumbs/metrics around:
- app visibility changes
- pageshow restore
- service worker controller changes
- service worker claim requests
- background client startup or failure
- forced reload requests

Expected results:

- enough telemetry exists to classify the freeze as controller churn, restore failure, background-client deadlock, or sync/network stall
73 changes: 35 additions & 38 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
selectedFiles.map((f) => f.file)
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isLongPress = useRef(false);

const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);

Expand Down Expand Up @@ -469,12 +467,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const isEncrypted = room.hasEncryptionStateEvent();

const { triggerPreLift } = useKeyboardHeight();
const isMobileLayout = mobileOrTablet();
// 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());
useScrollLock(isMobileLayout);

const closeSchedulePicker = useCallback(() => {
setShowSchedulePicker(false);
setScheduleMenuAnchor(undefined);
}, []);

const openSchedulePicker = useCallback(() => {
setSendError(undefined);
setScheduleMenuAnchor(undefined);
setShowSchedulePicker(true);
}, []);

useElementSizeObserver(
useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
Expand Down Expand Up @@ -1514,7 +1524,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div ref={ref} onMouseDown={mobileOrTablet() ? triggerPreLift : undefined}>
<div ref={ref} onMouseDown={isMobileLayout ? triggerPreLift : undefined}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
Expand Down Expand Up @@ -2025,8 +2035,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
size="300"
radii="300"
onClick={() => {
setScheduleMenuAnchor(undefined);
setShowSchedulePicker(true);
openSchedulePicker();
}}
before={menuIcon(Clock)}
>
Expand All @@ -2038,46 +2047,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
/>
<Box display="Flex" alignItems="Center">
{delayedEventsSupported && isMobileLayout && (
<IconButton
onClick={openSchedulePicker}
title="Schedule Message"
aria-label="Schedule message send"
variant={scheduledTime ? 'Primary' : 'SurfaceVariant'}
size="300"
radii="300"
className={css.AdjacentScheduleButton}
>
{composerIcon(Clock)}
</IconButton>
)}
<IconButton
title="Send Message"
aria-label="Send your composed Message"
onClick={() => {
if (isLongPress.current) {
isLongPress.current = false;
return;
}
submit();
}}
onClick={submit}
onMouseDown={(e: MouseEvent) => e.preventDefault()}
onPointerDown={() => {
isLongPress.current = false;
if (mobileOrTablet() && delayedEventsSupported) {
longPressTimer.current = setTimeout(() => {
isLongPress.current = true;
setShowSchedulePicker(true);
}, 1000);
}
}}
onPointerUp={() => {
if (longPressTimer.current !== null) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}}
onPointerCancel={() => {
if (longPressTimer.current !== null) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}}
variant={scheduledTime ? 'Primary' : 'SurfaceVariant'}
size="300"
radii="0"
className={delayedEventsSupported ? css.SplitSendButton : undefined}
className={
delayedEventsSupported && !isMobileLayout ? css.SplitSendButton : undefined
}
>
{scheduledTime ? composerIcon(Clock) : composerIcon(PaperPlaneTilt)}
</IconButton>
{delayedEventsSupported && !mobileOrTablet() && (
{delayedEventsSupported && !isMobileLayout && (
<IconButton
onClick={(evt: MouseEvent<HTMLButtonElement>) => {
setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect());
Expand All @@ -2101,10 +2098,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<SchedulePickerDialog
initialTime={scheduledTime?.getTime()}
showEncryptionWarning={isEncrypted}
onCancel={() => setShowSchedulePicker(false)}
onCancel={closeSchedulePicker}
onSubmit={(date) => {
setScheduledTime(date);
setShowSchedulePicker(false);
closeSchedulePicker();
setSendError(undefined);
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export const SplitChevronButton = style({
opacity: 0.7,
paddingInline: toRem(2),
});

export const AdjacentScheduleButton = style({
borderRadius: config.radii.R300,
marginInlineEnd: config.space.S100,
});
73 changes: 73 additions & 0 deletions src/app/hooks/timeline/useTimelineSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Room } from '$types/matrix-sdk';
import { ClientEvent, RoomEvent } from '$types/matrix-sdk';
import { appEvents } from '$utils/appEvents';
import { useTimelineSync } from './useTimelineSync';

vi.mock('@sentry/react', () => ({
Expand Down Expand Up @@ -241,6 +242,78 @@ describe('useTimelineSync', () => {
expect(scrollToBottom).not.toHaveBeenCalled();
});

it('preserves a loaded event context when ClientEvent.Room fires', async () => {
const { room } = createRoom('!room:test', [{ getId: () => '$live:event' }]);
const targetEvent = { getId: () => '$target:event' };
const contextTimeline = createTimeline([targetEvent]);
const mx = {
...createMx(),
getEventTimeline: vi.fn<() => Promise<FakeTimeline>>().mockResolvedValue(contextTimeline),
};

const { result } = renderHook(() =>
useTimelineSync({
room: room as Room,
mx: mx as never,
eventId: '$target:event',
isAtBottom: false,
isAtBottomRef: { current: false },
scrollToBottom: vi.fn<() => void>(),
unreadInfo: undefined,
setUnreadInfo: vi.fn<() => void>(),
hideReadsRef: { current: false },
readUptoEventIdRef: { current: undefined },
})
);

await act(async () => {
await result.current.loadEventTimeline('$target:event');
});

await act(async () => {
mx.emit(ClientEvent.Room, room);
await Promise.resolve();
});

expect(result.current.timeline.linkedTimelines).toEqual([contextTimeline]);
});

it('preserves a loaded event context when the app returns to foreground', async () => {
const { room } = createRoom('!room:test', [{ getId: () => '$live:event' }]);
const targetEvent = { getId: () => '$target:event' };
const contextTimeline = createTimeline([targetEvent]);
const mx = {
...createMx(),
getEventTimeline: vi.fn<() => Promise<FakeTimeline>>().mockResolvedValue(contextTimeline),
};

const { result } = renderHook(() =>
useTimelineSync({
room: room as Room,
mx: mx as never,
eventId: '$target:event',
isAtBottom: false,
isAtBottomRef: { current: false },
scrollToBottom: vi.fn<() => void>(),
unreadInfo: undefined,
setUnreadInfo: vi.fn<() => void>(),
hideReadsRef: { current: false },
readUptoEventIdRef: { current: undefined },
})
);

await act(async () => {
await result.current.loadEventTimeline('$target:event');
});

await act(async () => {
appEvents.emitVisibilityChange(true);
await Promise.resolve();
});

expect(result.current.timeline.linkedTimelines).toEqual([contextTimeline]);
});

it('does not snap a non-bottom user to latest after TimelineReset', async () => {
const { room, timelineSet, events } = createRoom();
const scrollToBottom = vi.fn<() => void>();
Expand Down
Loading
Loading