From e84cfcc9fde5b8667fa55bda12d66446cd133e2a Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 18 Aug 2026 22:31:28 +0200 Subject: [PATCH] fix: tauri media loading, sent-message drafts and composer buttons --- .changeset/enable-gif-sticker-triggers.md | 5 ++ .changeset/fix-mobile-composer-keyboard.md | 5 ++ .changeset/fix-sent-message-draft.md | 5 ++ .changeset/fix-tauri-media-loading.md | 5 ++ src-tauri/src/network/media_protocol.rs | 7 +- .../src/network/media_protocol/session.rs | 9 ++- .../editor/prosemirrorController.test.tsx | 6 +- .../editor/prosemirrorController.ts | 4 +- .../image-viewer/RoomMediaViewer.tsx | 11 ++- .../message/content/ImageContent.test.tsx | 37 ++++++++- .../message/content/ImageContent.tsx | 12 ++- .../message/content/ThumbnailContent.tsx | 10 ++- .../components/page/MobileNavDrawer.test.tsx | 75 ++++++++++++++---- src/app/components/page/MobileNavDrawer.tsx | 6 ++ .../room-avatar/RoomAvatar.test.tsx | 28 +++++-- src/app/components/room-avatar/RoomAvatar.tsx | 5 +- .../user-avatar/UserAvatar.test.tsx | 28 +++++-- src/app/components/user-avatar/UserAvatar.tsx | 5 +- src/app/features/room/RoomInput.test.tsx | 76 +++++++++++++++++-- src/app/features/room/RoomInput.tsx | 16 +++- src/app/hooks/useRenderableMediaUrl.test.tsx | 32 ++++++++ src/app/hooks/useRenderableMediaUrl.ts | 8 ++ src/app/state/settings.defaults.test.ts | 28 +++++++ src/app/state/settings.ts | 12 ++- src/app/utils/mediaUrl.test.ts | 22 ++++++ src/app/utils/mediaUrl.ts | 11 +++ 26 files changed, 397 insertions(+), 71 deletions(-) create mode 100644 .changeset/enable-gif-sticker-triggers.md create mode 100644 .changeset/fix-mobile-composer-keyboard.md create mode 100644 .changeset/fix-sent-message-draft.md create mode 100644 .changeset/fix-tauri-media-loading.md diff --git a/.changeset/enable-gif-sticker-triggers.md b/.changeset/enable-gif-sticker-triggers.md new file mode 100644 index 0000000000..d82e1e4d13 --- /dev/null +++ b/.changeset/enable-gif-sticker-triggers.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Show the gif and sticker composer buttons by default, keeping only emoji while typing on mobile. diff --git a/.changeset/fix-mobile-composer-keyboard.md b/.changeset/fix-mobile-composer-keyboard.md new file mode 100644 index 0000000000..6b78641746 --- /dev/null +++ b/.changeset/fix-mobile-composer-keyboard.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix the mobile keyboard staying open over the room list after swiping out of a room. diff --git a/.changeset/fix-sent-message-draft.md b/.changeset/fix-sent-message-draft.md new file mode 100644 index 0000000000..8e800e720d --- /dev/null +++ b/.changeset/fix-sent-message-draft.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix a sent message coming back in the composer, and stop undo restoring cleared content. diff --git a/.changeset/fix-tauri-media-loading.md b/.changeset/fix-tauri-media-loading.md new file mode 100644 index 0000000000..e13bfaa058 --- /dev/null +++ b/.changeset/fix-tauri-media-loading.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix avatars, images and videos disappearing or reloading on desktop and mobile. diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 4050fc0d33..e95975805f 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -110,9 +110,12 @@ impl MediaSessionState { } fn set_session(&self, session: MediaSession) -> Result<(), String> { - self.session_store.set(session, || { + self.session_store.set(session, |changed| { self.forget_client_errors(); - self.clear_loopback_media(); + // Capabilities embed the access token, so only a real session change orphans them. + if changed { + self.clear_loopback_media(); + } }) } diff --git a/src-tauri/src/network/media_protocol/session.rs b/src-tauri/src/network/media_protocol/session.rs index 89153e6e16..24918afea0 100644 --- a/src-tauri/src/network/media_protocol/session.rs +++ b/src-tauri/src/network/media_protocol/session.rs @@ -100,16 +100,21 @@ impl SessionStore { pub(super) fn set( &self, mut session: MediaSession, - before_notify: impl FnOnce(), + before_notify: impl FnOnce(bool), ) -> Result<(), String> { let mut current = self .current .write() .map_err(|_| "media session lock poisoned".to_owned())?; + let changed = current.as_ref().is_none_or(|existing| { + existing.origin != session.origin + || existing.token != session.token + || existing.scope != session.scope + }); session.generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; *current = Some(session); drop(current); - before_notify(); + before_notify(changed); self.ever_set.store(true, Ordering::Release); self.ready.notify_waiters(); Ok(()) diff --git a/src/app/components/editor/prosemirrorController.test.tsx b/src/app/components/editor/prosemirrorController.test.tsx index 83886bcff4..16d68b45e4 100644 --- a/src/app/components/editor/prosemirrorController.test.tsx +++ b/src/app/components/editor/prosemirrorController.test.tsx @@ -235,8 +235,8 @@ describe('clipboard', () => { }); }); -describe('ProseMirrorEditorController clearHistory', () => { - it('keeps undo working while composing, then wipes it after the send', () => { +describe('ProseMirrorEditorController clear', () => { + it('keeps undo working while composing, then wipes it once cleared', () => { const { controller } = mount(); controller.insertText('hello'); @@ -245,7 +245,6 @@ describe('ProseMirrorEditorController clearHistory', () => { controller.insertText('draft'); controller.clear(); - controller.clearHistory(); controller.undo(); expect(controller.getDocument()).toEqual(doc('')); }); @@ -255,7 +254,6 @@ describe('ProseMirrorEditorController clearHistory', () => { editable.focus(); act(() => controller.clear()); - act(() => controller.clearHistory()); expect(editable).toBe(document.activeElement); expect(editable).toHaveAttribute('data-placeholder-visible', 'true'); diff --git a/src/app/components/editor/prosemirrorController.ts b/src/app/components/editor/prosemirrorController.ts index 2322326f8e..13c866469c 100644 --- a/src/app/components/editor/prosemirrorController.ts +++ b/src/app/components/editor/prosemirrorController.ts @@ -186,11 +186,9 @@ export class ProseMirrorEditorController { this.view?.focus(); } + /** Drops the undo stack too: cleared content was sent, consumed, or abandoned. */ clear(): void { this.setDocument(emptyEditorDocument()); - } - - clearHistory(): void { if (this.view) this.view.updateState(this.createState()); } diff --git a/src/app/components/image-viewer/RoomMediaViewer.tsx b/src/app/components/image-viewer/RoomMediaViewer.tsx index f47012f78f..0a056546a3 100644 --- a/src/app/components/image-viewer/RoomMediaViewer.tsx +++ b/src/app/components/image-viewer/RoomMediaViewer.tsx @@ -13,7 +13,7 @@ import { mxcUrlToHttp, rewriteAuthenticatedMediaUrl, } from '$utils/matrix'; -import { getTauriMediaSourceUrl } from '$utils/mediaUrl'; +import { getTauriMediaSourceUrl, prepareLoopbackImageSource } from '$utils/mediaUrl'; import { FALLBACK_MIMETYPE } from '$utils/mimeTypes'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { isTauri } from '@tauri-apps/api/core'; @@ -70,8 +70,10 @@ function ResolvedRoomMedia({ () => (item.url.startsWith('http') ? item.url : mxcUrlToHttp(mx, item.url, useAuthentication)), [item.url, mx, useAuthentication] ); + const tauri = isTauri(); + // Tauri resolves the source inside the effect instead. const resolvedMediaUrl = useRenderableMediaUrl( - item.encInfo ? undefined : (rawMediaUrl ?? undefined) + item.encInfo || tauri ? undefined : (rawMediaUrl ?? undefined) ); const [resolved, setResolved] = useState(); @@ -87,7 +89,7 @@ function ResolvedRoomMedia({ const { encInfo, mimeType } = item; if (encInfo) { if (!rawMediaUrl) throw new Error('Invalid media URL'); - if (isTauri()) { + if (tauri) { await setMediaEncryption(rawMediaUrl, encInfo, mimeType ?? FALLBACK_MIMETYPE); return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; } @@ -97,6 +99,7 @@ function ResolvedRoomMedia({ ) ); } + if (tauri && rawMediaUrl) return prepareLoopbackImageSource(rawMediaUrl); return resolvedMediaUrl ?? rawMediaUrl ?? item.url; }; @@ -109,7 +112,7 @@ function ResolvedRoomMedia({ if (requestRef.current !== request) return; setError(err instanceof Error ? err : new Error('Failed to load media')); }); - }, [item, rawMediaUrl, resolvedMediaUrl, createObjectURL, retryToken]); + }, [item, rawMediaUrl, resolvedMediaUrl, tauri, createObjectURL, retryToken]); const loading = !error && resolved?.item.eventId !== item.eventId; const showingResolved = resolved?.item.eventId === item.eventId; diff --git a/src/app/components/message/content/ImageContent.test.tsx b/src/app/components/message/content/ImageContent.test.tsx index deff648c92..3de592bb1c 100644 --- a/src/app/components/message/content/ImageContent.test.tsx +++ b/src/app/components/message/content/ImageContent.test.tsx @@ -4,7 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { ImageContent } from './ImageContent'; import { downloadEncryptedMedia, mxcUrlToHttp } from '$utils/matrix'; -const screenMocks = vi.hoisted(() => ({ isMobile: true, tauri: false })); +const screenMocks = vi.hoisted(() => ({ + isMobile: true, + tauri: false, + loopbackUrl: undefined as string | undefined, +})); vi.mock('$hooks/useScreenSize', () => ({ ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, useScreenSizeOptionally: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'), @@ -13,6 +17,10 @@ vi.mock('$hooks/useScreenSize', () => ({ vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => screenMocks.tauri, + invoke: async () => { + if (!screenMocks.loopbackUrl) throw new Error('loopback media server unavailable'); + return screenMocks.loopbackUrl; + }, // Real convertFileSrc percent-encodes the target into the URI path. convertFileSrc: (url: string, protocol: string) => `${protocol}://localhost/${encodeURIComponent(url)}`, @@ -201,6 +209,33 @@ describe('ImageContent', () => { } }); + it('loads a Tauri image once, from the loopback origin', async () => { + screenMocks.tauri = true; + screenMocks.loopbackUrl = 'http://127.0.0.1:45678/capability'; + try { + const srcs: string[] = []; + render( + { + srcs.push(props.src); + return preview; + }} + renderViewer={() =>
viewer
} + /> + ); + + touchTap(screen.getByRole('button', { name: 'View' })); + await screen.findByAltText('preview'); + + await waitFor(() => expect(srcs.length).toBeGreaterThan(0)); + expect(Array.from(new Set(srcs))).toEqual(['http://127.0.0.1:45678/capability']); + } finally { + screenMocks.tauri = false; + screenMocks.loopbackUrl = undefined; + } + }); + it('unwraps the Tauri media URL before downloading an encrypted image', async () => { screenMocks.tauri = true; const renderViewer = vi.fn<(props: { getDownloadBlob?: () => Promise }) => ReactNode>( diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 03e7b68d1e..e62f582582 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -44,6 +44,7 @@ import { addTauriMediaRetryRevision, getTauriMediaRetryTarget, getTauriMediaSourceUrl, + prepareLoopbackImageSource, } from '$utils/mediaUrl'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { isTauri } from '@tauri-apps/api/core'; @@ -225,8 +226,10 @@ export const ImageContent = as<'div', ImageContentProps>( }, [mx, url, useAuthentication, usesThumbnail, thumbWidth, thumbHeight]); const shouldResolveMedia = !deferMediaLoad || autoPlay || loadRequested; + const tauri = isTauri(); + // Tauri resolves the source inside `loadSrc` instead. const resolvedMediaUrl = useRenderableMediaUrl( - encInfo || !shouldResolveMedia ? undefined : rawMediaUrl + encInfo || tauri || !shouldResolveMedia ? undefined : rawMediaUrl ); const createObjectURL = useCreateObjectURL(); @@ -235,7 +238,7 @@ export const ImageContent = as<'div', ImageContentProps>( useCallback(async () => { if (encInfo) { if (!rawMediaUrl) throw new Error('Invalid media URL'); - if (isTauri()) { + if (tauri) { // The registration key is the revised target; Rust strips the fragment. const attemptedTarget = getTauriMediaRetryTarget(rawMediaUrl, retryRevisionRef.current) ?? rawMediaUrl; @@ -248,11 +251,12 @@ export const ImageContent = as<'div', ImageContentProps>( ) ); } - return addTauriMediaRetryRevision( + const source = addTauriMediaRetryRevision( resolvedMediaUrl ?? rawMediaUrl ?? url, retryRevisionRef.current ); - }, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo, createObjectURL]) + return tauri && rawMediaUrl ? prepareLoopbackImageSource(source) : source; + }, [rawMediaUrl, resolvedMediaUrl, tauri, url, mimeType, encInfo, createObjectURL]) ); useEffect(() => { diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index e19177a248..c3f2dda5a1 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -10,6 +10,7 @@ import { mxcUrlToHttp, rewriteAuthenticatedMediaUrl, } from '$utils/matrix'; +import { prepareLoopbackImageSource } from '$utils/mediaUrl'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { isTauri } from '@tauri-apps/api/core'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; @@ -33,7 +34,9 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { return mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? undefined; }, [mx, thumbMxcUrl, useAuthentication]); - const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl); + const tauri = isTauri(); + // Tauri resolves the source inside `loadThumbSrc` instead. + const resolvedMediaUrl = useRenderableMediaUrl(encInfo || tauri ? undefined : rawMediaUrl); const createObjectURL = useCreateObjectURL(); @@ -47,7 +50,7 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { } if (encInfo) { if (!rawMediaUrl) throw new Error('Invalid media URL'); - if (isTauri()) { + if (tauri) { await setMediaEncryption(rawMediaUrl, encInfo, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE); return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; } @@ -57,8 +60,9 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { ) ); } + if (tauri && rawMediaUrl) return prepareLoopbackImageSource(rawMediaUrl); return resolvedMediaUrl ?? rawMediaUrl ?? thumbMxcUrl; - }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo, createObjectURL]) + }, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, tauri, encInfo, createObjectURL]) ); useEffect(() => { diff --git a/src/app/components/page/MobileNavDrawer.test.tsx b/src/app/components/page/MobileNavDrawer.test.tsx index 2f74afc6dd..64e3d9ac0a 100644 --- a/src/app/components/page/MobileNavDrawer.test.tsx +++ b/src/app/components/page/MobileNavDrawer.test.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useRef } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; import { MobileNavDrawer } from './MobileNavDrawer'; import { useMobileNavDrawer } from './MobileNavDrawerContext'; @@ -10,23 +10,32 @@ vi.mock('$state/hooks/settings', () => ({ })); vi.mock('./PersistentRoomHost', () => ({ - PersistentRoomHost: () =>
, + PersistentRoomHost: () => ( +
+ +
+ ), })); +let reduceMotion = false; + +afterEach(() => { + reduceMotion = false; + vi.restoreAllMocks(); +}); + beforeAll(() => { - window.matchMedia = - window.matchMedia ?? - ((query: string) => - ({ - matches: false, - media: query, - addEventListener: () => {}, - removeEventListener: () => {}, - addListener: () => {}, - removeListener: () => {}, - onchange: null, - dispatchEvent: () => false, - }) as unknown as MediaQueryList); + window.matchMedia = (query: string) => + ({ + matches: query.includes('prefers-reduced-motion') ? reduceMotion : false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; }); const renderDrawer = () => @@ -79,6 +88,21 @@ function ChatSwipeProbe({ ); } +function BackToListHarness() { + const navigate = useNavigate(); + return ( + navigate('/home')}> + back to list + + } + > +
content
+
+ ); +} + describe('MobileNavDrawer', () => { // The panels sit side by side in a track twice the viewport wide, moved by transform. // `hidden` leaves a scrollport that focus or scrollIntoView scrolls a full panel width, @@ -92,6 +116,25 @@ describe('MobileNavDrawer', () => { expect(viewport.style.overflow).not.toBe('hidden'); }); + it('drops focus from the panel that slides out of view', () => { + reduceMotion = true; + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400); + + render( + + + + ); + + const composer = screen.getByLabelText('composer'); + composer.focus(); + expect(document.activeElement).toBe(composer); + + fireEvent.click(screen.getByRole('button', { name: 'back to list' })); + + expect(document.activeElement).not.toBe(composer); + }); + it('still drives message swipe when the gesture starts on a nested ignored element (e.g. an image)', () => { const move = vi.fn<(distanceX: number) => void>(); diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index 959fef3822..0d9850296e 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -214,6 +214,12 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra useLayoutEffect(() => { navPanelRef.current?.toggleAttribute('inert', panelIntent === 1); contentPanelRef.current?.toggleAttribute('inert', panelIntent === 0); + // `inert` alone does not drop a mobile keyboard. + const hiddenPanel = panelIntent === 0 ? contentPanelRef.current : navPanelRef.current; + const focused = document.activeElement; + if (hiddenPanel && focused instanceof HTMLElement && hiddenPanel.contains(focused)) { + focused.blur(); + } }, [panelIntent]); useLayoutEffect(() => { diff --git a/src/app/components/room-avatar/RoomAvatar.test.tsx b/src/app/components/room-avatar/RoomAvatar.test.tsx index a56ae38c3d..0d68ace399 100644 --- a/src/app/components/room-avatar/RoomAvatar.test.tsx +++ b/src/app/components/room-avatar/RoomAvatar.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const media = vi.hoisted(() => ({ - useRenderableMediaUrl: vi.fn<(url: string | undefined) => string | undefined>(), + useRenderableMediaSource: vi.fn<(url: string | undefined) => string | undefined>(), })); vi.mock('$hooks/useRenderableMediaUrl', () => media); @@ -12,23 +12,39 @@ const RAW_SRC = 'https://example.org/_matrix/client/v1/media/thumbnail/example.o describe('RoomAvatar', () => { beforeEach(() => { vi.resetModules(); - media.useRenderableMediaUrl.mockReset(); + media.useRenderableMediaSource.mockReset(); }); - it('shows the image once the resolved url arrives after a failed raw request', async () => { - media.useRenderableMediaUrl.mockReturnValue(undefined); + it('waits for a renderable url instead of requesting the raw one', async () => { + media.useRenderableMediaSource.mockReturnValue(undefined); const { RoomAvatar } = await import('./RoomAvatar'); const { rerender } = render( 'RM'} /> ); - fireEvent.error(screen.getByRole('img')); expect(screen.queryByRole('img')).not.toBeInTheDocument(); - media.useRenderableMediaUrl.mockReturnValue('blob:resolved-avatar'); + media.useRenderableMediaSource.mockReturnValue('blob:resolved-avatar'); rerender( 'RM'} />); expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:resolved-avatar'); }); + + it('falls back until a new url arrives when the image fails to load', async () => { + media.useRenderableMediaSource.mockReturnValue('blob:resolved-avatar'); + const { RoomAvatar } = await import('./RoomAvatar'); + + const { rerender } = render( + 'RM'} /> + ); + + fireEvent.error(screen.getByRole('img')); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + + media.useRenderableMediaSource.mockReturnValue('blob:next-avatar'); + rerender( 'RM'} />); + + expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:next-avatar'); + }); }); diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index 4c46e7ebe9..dd37820a48 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -12,7 +12,7 @@ import { getRoomStandaloneIconComponent, } from '$components/icons/roomIcons'; import colorMXID from '$utils/colorMXID'; -import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; +import { useRenderableMediaSource } from '$hooks/useRenderableMediaUrl'; import * as css from './RoomAvatar.css'; import { AvatarImage } from './AvatarImage'; @@ -26,8 +26,7 @@ type RoomAvatarProps = { export function RoomAvatar({ roomId, src, alt, renderFallback, uniformIcons }: RoomAvatarProps) { const [error, setError] = useState(false); - const resolvedSrc = useRenderableMediaUrl(src); - const mediaSrc = resolvedSrc ?? src; + const mediaSrc = useRenderableMediaSource(src); useEffect(() => { setError(false); diff --git a/src/app/components/user-avatar/UserAvatar.test.tsx b/src/app/components/user-avatar/UserAvatar.test.tsx index c0e24d223f..0e742fd78f 100644 --- a/src/app/components/user-avatar/UserAvatar.test.tsx +++ b/src/app/components/user-avatar/UserAvatar.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const media = vi.hoisted(() => ({ - useRenderableMediaUrl: vi.fn<(url: string | undefined) => string | undefined>(), + useRenderableMediaSource: vi.fn<(url: string | undefined) => string | undefined>(), })); vi.mock('$hooks/useRenderableMediaUrl', () => media); @@ -12,23 +12,39 @@ const RAW_SRC = 'https://example.org/_matrix/client/v1/media/thumbnail/example.o describe('UserAvatar', () => { beforeEach(() => { vi.resetModules(); - media.useRenderableMediaUrl.mockReset(); + media.useRenderableMediaSource.mockReset(); }); - it('shows the image once the resolved url arrives after a failed raw request', async () => { - media.useRenderableMediaUrl.mockReturnValue(undefined); + it('waits for a renderable url instead of requesting the raw one', async () => { + media.useRenderableMediaSource.mockReturnValue(undefined); const { UserAvatar } = await import('./UserAvatar'); const { rerender } = render( 'US'} /> ); - fireEvent.error(screen.getByRole('img')); expect(screen.queryByRole('img')).not.toBeInTheDocument(); - media.useRenderableMediaUrl.mockReturnValue('blob:resolved-avatar'); + media.useRenderableMediaSource.mockReturnValue('blob:resolved-avatar'); rerender( 'US'} />); expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:resolved-avatar'); }); + + it('falls back until a new url arrives when the image fails to load', async () => { + media.useRenderableMediaSource.mockReturnValue('blob:resolved-avatar'); + const { UserAvatar } = await import('./UserAvatar'); + + const { rerender } = render( + 'US'} /> + ); + + fireEvent.error(screen.getByRole('img')); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + + media.useRenderableMediaSource.mockReturnValue('blob:next-avatar'); + rerender( 'US'} />); + + expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:next-avatar'); + }); }); diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx index 2247adbd64..5a174af1e8 100644 --- a/src/app/components/user-avatar/UserAvatar.tsx +++ b/src/app/components/user-avatar/UserAvatar.tsx @@ -3,7 +3,7 @@ import type { ReactEventHandler, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import classNames from 'classnames'; import colorMXID from '$utils/colorMXID'; -import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; +import { useRenderableMediaSource } from '$hooks/useRenderableMediaUrl'; import * as css from './UserAvatar.css'; type UserAvatarProps = { @@ -28,8 +28,7 @@ export function UserAvatar({ renderFallback, }: UserAvatarProps) { const [error, setError] = useState(false); - const resolvedSrc = useRenderableMediaUrl(src); - const mediaSrc = resolvedSrc ?? src; + const mediaSrc = useRenderableMediaSource(src); useEffect(() => { setError(false); diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx index c23b39fe28..5ffc56fe79 100644 --- a/src/app/features/room/RoomInput.test.tsx +++ b/src/app/features/room/RoomInput.test.tsx @@ -28,6 +28,7 @@ import { roomScheduleCoordinator } from '$state/room/roomScheduleCoordinator'; const testState = vi.hoisted(() => ({ isMobile: false, + editorTriggerButtons: false, matrix: { sendMessage: vi.fn(), sendEvent: vi.fn(), @@ -63,11 +64,11 @@ vi.mock('$state/hooks/settings', () => ({ useSetting: (_atom: unknown, key: string) => { const values: Record = { enterForNewline: false, - editorGifButton: false, - editorEmojiButton: false, - editorStickerButton: false, + editorGifButton: testState.editorTriggerButtons, + editorEmojiButton: testState.editorTriggerButtons, + editorStickerButton: testState.editorTriggerButtons, editorMicButton: false, - editorButtonOrder: [], + editorButtonOrder: testState.editorTriggerButtons ? ['gif', 'sticker', 'emoji'] : [], shortcutOverrides: {}, hideActivity: true, mentionInReplies: true, @@ -761,6 +762,7 @@ function deferred() { beforeEach(() => { testState.isMobile = false; + testState.editorTriggerButtons = false; testState.pendingUploads = []; testState.sendIndividualAttachmentAsCaption = false; testState.encrypted = false; @@ -1109,8 +1111,8 @@ describe('RoomInput submit regressions', () => { expect(document.activeElement).toBe(screen.getByTestId('room-input-editor')); }); - it('wipes the composer undo history when a message is sent', async () => { - const clearHistorySpy = vi.spyOn(ProseMirrorEditorController.prototype, 'clearHistory'); + it('clears the composer when a message is sent', async () => { + const clearSpy = vi.spyOn(ProseMirrorEditorController.prototype, 'clear'); try { render(); fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); @@ -1118,9 +1120,9 @@ describe('RoomInput submit regressions', () => { fireEvent.click(sendButton()); await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); - expect(clearHistorySpy).toHaveBeenCalledOnce(); + expect(clearSpy).toHaveBeenCalled(); } finally { - clearHistorySpy.mockRestore(); + clearSpy.mockRestore(); } }); @@ -1395,6 +1397,64 @@ describe('RoomInput submit regressions', () => { ); }); + it('leaves only the emoji trigger once text is composed on mobile', () => { + testState.isMobile = true; + testState.editorTriggerButtons = true; + render(); + + expect(screen.getByRole('button', { name: 'Open gif picker' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open sticker picker' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + expect(screen.queryByRole('button', { name: 'Open gif picker' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open sticker picker' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open emoji board' })).toBeInTheDocument(); + }); + + it('keeps every trigger while composing on desktop', () => { + testState.editorTriggerButtons = true; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + expect(screen.getByRole('button', { name: 'Open gif picker' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open sticker picker' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open emoji board' })).toBeInTheDocument(); + }); + + it('keeps an unsent draft across a composer remount', async () => { + const input = render(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + input.unmount(); + expect(screen.getByTestId('draft-observer')).toHaveTextContent('retry me'); + + render(); + + expect(screen.getByTestId('room-input-editor')).toHaveAttribute('data-editor-text', 'retry me'); + }); + + it('drops the persisted draft when its message is sent', async () => { + const seed = render(); + seed.unmount(); + render(); + render(); + + await waitFor(() => + expect(screen.getByTestId('room-input-editor')).toHaveAttribute( + 'data-editor-text', + 'draft to send' + ) + ); + + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(screen.getByTestId('draft-observer')).toBeEmptyDOMElement(); + }); + it('preserves the normal draft when an edited message input unmounts', async () => { testState.isMobile = true; testState.editingEvent = { diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 4369c9eb84..0ef22a1af7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -763,7 +763,12 @@ export const RoomInput = forwardRef( }); }, [threadRootId, setReplyDraft, mx]); + // Rewritten with equal content on unmount, and appending it again would duplicate it. + const appliedDraftRef = useRef(); useEffect(() => { + const draft = JSON.stringify(msgDraft); + if (appliedDraftRef.current === draft) return; + appliedDraftRef.current = draft; editor.appendDocument(msgDraft); }, [editor, msgDraft]); @@ -1070,13 +1075,14 @@ export const RoomInput = forwardRef( }; if (clearEditor) { editor.clear(); - editor.clearHistory(); + // The draft outlives this component, and a remount re-applies it. + setMsgDraft([]); imagePacksUsedRef.current.clear(); sendTypingStatus(false); } return submission; }, - [claimReply, editor, sendTypingStatus] + [claimReply, editor, sendTypingStatus, setMsgDraft] ); const restoreSubmission = useCallback( (submission: Submission) => { @@ -2356,11 +2362,13 @@ export const RoomInput = forwardRef( requestClose={closeEmojiBoard} /> ); + // Mobile has no room for three triggers next to text. + const onlyEmojiTrigger = isMobile && hasText; const triggers = ( <> {editorButtonOrder.map((id) => { let button: ReactElement | null = null; - if (id === 'gif' && editorGifButton) { + if (id === 'gif' && editorGifButton && !onlyEmojiTrigger) { button = ( ( })} ); - } else if (id === 'sticker' && editorStickerButton) { + } else if (id === 'sticker' && editorStickerButton && !onlyEmojiTrigger) { button = ( { }); expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); }); + + it('withholds the raw source under Tauri until the loopback url resolves', async () => { + tauriApi.isTauri.mockReturnValue(true); + let resolveLoopback: (url: string) => void = () => {}; + tauriApi.invoke.mockReturnValue( + new Promise((resolve) => { + resolveLoopback = resolve; + }) + ); + const { useRenderableMediaSource } = await import('./useRenderableMediaUrl'); + + const rawUrl = + 'sable-media://https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123'; + const { result } = renderHook(() => useRenderableMediaSource(rawUrl)); + + expect(result.current).toBeUndefined(); + + await act(async () => { + resolveLoopback(LOOPBACK_URL); + }); + await waitFor(() => expect(result.current).toBe(LOOPBACK_URL)); + }); + + it('falls back to the raw source outside Tauri while the blob resolves', async () => { + tauriApi.isTauri.mockReturnValue(false); + mediaTransport.fetchMediaBlob.mockReturnValue(new Promise(() => {})); + const { useRenderableMediaSource } = await import('./useRenderableMediaUrl'); + + const { result } = renderHook(() => useRenderableMediaSource('https://example.org/avatar.png')); + + expect(result.current).toBe('https://example.org/avatar.png'); + }); }); diff --git a/src/app/hooks/useRenderableMediaUrl.ts b/src/app/hooks/useRenderableMediaUrl.ts index 5c64728f45..2de00f7b53 100644 --- a/src/app/hooks/useRenderableMediaUrl.ts +++ b/src/app/hooks/useRenderableMediaUrl.ts @@ -286,3 +286,11 @@ export function useRenderableMediaUrl(url: string | undefined): string | undefin return resolvedState.url; } + +// Undefined while the url is still being prepared: on Tauri, rendering the raw source first +// only means a second load once the loopback url lands, and an error on either latches. +export function useRenderableMediaSource(url: string | undefined): string | undefined { + const resolvedUrl = useRenderableMediaUrl(url); + if (resolvedUrl) return resolvedUrl; + return isTauri() ? undefined : url; +} diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index 57cd738b65..cb85869c96 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -72,6 +72,34 @@ describe('mergePersistedSettings', () => { expect(merged.saturationLevel).toBe(0); }); + it('turns the gif and sticker triggers on once for clients persisted before the migration', () => { + expect(defaultSettings.editorGifButton).toBe(true); + expect(defaultSettings.editorStickerButton).toBe(true); + + localStorage.setItem( + 'settings', + JSON.stringify({ editorGifButton: false, editorStickerButton: false }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.editorGifButton).toBe(true); + expect(merged.editorStickerButton).toBe(true); + expect(merged.editorTriggerButtonsMigrated).toBe(true); + }); + + it('keeps the trigger buttons off once the migration has run', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + editorGifButton: false, + editorStickerButton: false, + editorTriggerButtonsMigrated: true, + }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.editorGifButton).toBe(false); + expect(merged.editorStickerButton).toBe(false); + }); + it('seeds the name color correction once for clients persisted before the migration', () => { localStorage.setItem('settings', JSON.stringify({ nameColorLightnessCorrection: 'off' })); const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 2fcd7a0c1c..22e5287c7f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -130,6 +130,7 @@ export interface Settings { editorEmojiButton: boolean; editorGifButton: boolean; editorStickerButton: boolean; + editorTriggerButtonsMigrated: boolean; editorButtonOrder: EditorButtonId[]; composerToolbarOpen: boolean; alwaysInlineEditor: boolean; @@ -316,8 +317,9 @@ export const defaultSettings: Settings = { editorOldAddFile: false, editorMicButton: true, editorEmojiButton: true, - editorGifButton: false, - editorStickerButton: false, + editorGifButton: true, + editorStickerButton: true, + editorTriggerButtonsMigrated: true, editorButtonOrder: [...EDITOR_BUTTON_ORDER_DEFAULT], composerToolbarOpen: false, alwaysInlineEditor: false, @@ -522,6 +524,12 @@ function migrateParsedLocalStorage(parsed: Record): void { } delete parsed.monochromeMode; + if (parsed.editorTriggerButtonsMigrated !== true) { + delete parsed.editorGifButton; + delete parsed.editorStickerButton; + parsed.editorTriggerButtonsMigrated = true; + } + if (parsed.nameColorLightnessCorrectionMigrated !== true) { delete parsed.nameColorLightnessCorrection; parsed.nameColorLightnessCorrectionMigrated = true; diff --git a/src/app/utils/mediaUrl.test.ts b/src/app/utils/mediaUrl.test.ts index c8896d4df7..6dd8a01e31 100644 --- a/src/app/utils/mediaUrl.test.ts +++ b/src/app/utils/mediaUrl.test.ts @@ -27,6 +27,7 @@ import { addTauriMediaRetryRevision, getTauriMediaSourceUrl, getTauriMediaRetryTarget, + prepareLoopbackImageSource, prepareLoopbackMedia, rewriteAuthenticatedMediaUrl, } from './mediaUrl'; @@ -274,3 +275,24 @@ describe('prepareLoopbackMedia', () => { ); }); }); + +describe('prepareLoopbackImageSource', () => { + it('resolves the loopback URL under Tauri', async () => { + hoistedIsTauri.mockReturnValue(true); + hoistedInvoke.mockResolvedValue('http://127.0.0.1:45678/capability'); + + await expect(prepareLoopbackImageSource('sable-media://localhost/media')).resolves.toBe( + 'http://127.0.0.1:45678/capability' + ); + }); + + // Unlike a media element, an image renders from the custom scheme just fine. + it('degrades to the custom-scheme URL when the loopback fails', async () => { + hoistedIsTauri.mockReturnValue(true); + hoistedInvoke.mockRejectedValue(new Error('loopback media server unavailable')); + + await expect(prepareLoopbackImageSource('sable-media://localhost/media')).resolves.toBe( + 'sable-media://localhost/media' + ); + }); +}); diff --git a/src/app/utils/mediaUrl.ts b/src/app/utils/mediaUrl.ts index d8941a8142..bb6c6386a8 100644 --- a/src/app/utils/mediaUrl.ts +++ b/src/app/utils/mediaUrl.ts @@ -115,6 +115,17 @@ export const prepareLoopbackMedia = async (source: string): Promise => { return invoke('prepare_loopback_media', { url: source }); }; +// Awaited by the caller's own load so an element's src never changes mid-flight. An image +// renders from the custom protocol too, so a loopback failure costs caching, not the image. +export const prepareLoopbackImageSource = async (source: string): Promise => { + if (!isTauri()) return source; + try { + return await prepareLoopbackMedia(source); + } catch { + return source; + } +}; + export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string,