Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/enable-gif-sticker-triggers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Show the gif and sticker composer buttons by default, keeping only emoji while typing on mobile.
5 changes: 5 additions & 0 deletions .changeset/fix-mobile-composer-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix the mobile keyboard staying open over the room list after swiping out of a room.
5 changes: 5 additions & 0 deletions .changeset/fix-sent-message-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix a sent message coming back in the composer, and stop undo restoring cleared content.
5 changes: 5 additions & 0 deletions .changeset/fix-tauri-media-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix avatars, images and videos disappearing or reloading on desktop and mobile.
7 changes: 5 additions & 2 deletions src-tauri/src/network/media_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
})
}

Expand Down
9 changes: 7 additions & 2 deletions src-tauri/src/network/media_protocol/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
6 changes: 2 additions & 4 deletions src/app/components/editor/prosemirrorController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -245,7 +245,6 @@ describe('ProseMirrorEditorController clearHistory', () => {

controller.insertText('draft');
controller.clear();
controller.clearHistory();
controller.undo();
expect(controller.getDocument()).toEqual(doc(''));
});
Expand All @@ -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');
Expand Down
4 changes: 1 addition & 3 deletions src/app/components/editor/prosemirrorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
11 changes: 7 additions & 4 deletions src/app/components/image-viewer/RoomMediaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ResolvedMedia>();
Expand All @@ -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)!;
}
Expand All @@ -97,6 +99,7 @@ function ResolvedRoomMedia({
)
);
}
if (tauri && rawMediaUrl) return prepareLoopbackImageSource(rawMediaUrl);
return resolvedMediaUrl ?? rawMediaUrl ?? item.url;
};

Expand All @@ -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;
Expand Down
37 changes: 36 additions & 1 deletion src/app/components/message/content/ImageContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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)}`,
Expand Down Expand Up @@ -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(
<ImageContent
url="mxc://example.org/abc123"
renderImage={(props) => {
srcs.push(props.src);
return <img alt="preview" src={props.src} onError={props.onError} />;
}}
renderViewer={() => <div>viewer</div>}
/>
);

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<Blob> }) => ReactNode>(
Expand Down
12 changes: 8 additions & 4 deletions src/app/components/message/content/ImageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
addTauriMediaRetryRevision,
getTauriMediaRetryTarget,
getTauriMediaSourceUrl,
prepareLoopbackImageSource,
} from '$utils/mediaUrl';
import { setMediaEncryption } from '$utils/tauriMediaEncryption';
import { isTauri } from '@tauri-apps/api/core';
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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(() => {
Expand Down
10 changes: 7 additions & 3 deletions src/app/components/message/content/ThumbnailContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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)!;
}
Expand All @@ -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(() => {
Expand Down
75 changes: 59 additions & 16 deletions src/app/components/page/MobileNavDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,23 +10,32 @@ vi.mock('$state/hooks/settings', () => ({
}));

vi.mock('./PersistentRoomHost', () => ({
PersistentRoomHost: () => <div data-testid="persistent-room-host" />,
PersistentRoomHost: () => (
<div data-testid="persistent-room-host">
<input aria-label="composer" />
</div>
),
}));

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 = () =>
Expand Down Expand Up @@ -79,6 +88,21 @@ function ChatSwipeProbe({
);
}

function BackToListHarness() {
const navigate = useNavigate();
return (
<MobileNavDrawer
nav={
<button type="button" onClick={() => navigate('/home')}>
back to list
</button>
}
>
<div>content</div>
</MobileNavDrawer>
);
}

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,
Expand All @@ -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(
<MemoryRouter initialEntries={['/home/!room:example.org']}>
<BackToListHarness />
</MemoryRouter>
);

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>();

Expand Down
Loading
Loading