Skip to content

Commit 8269ebd

Browse files
authored
fix(media): drop the stale loopback url when the media source changes (#1855)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Fixes # #### Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents dc5f974 + 1eaaf4d commit 8269ebd

7 files changed

Lines changed: 110 additions & 17 deletions

File tree

src/app/components/room-avatar/AvatarImage.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useState } from 'react';
44
import bgColorImg from '$utils/bgColorImg';
55
import { settingsAtom } from '$state/settings';
66
import { useSetting } from '$state/hooks/settings';
7-
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
87
import * as css from './RoomAvatar.css';
98

109
type AvatarImageProps = {
@@ -17,8 +16,6 @@ type AvatarImageProps = {
1716
export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) {
1817
const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons');
1918
const [image, setImage] = useState<HTMLImageElement | undefined>(undefined);
20-
const resolvedSrc = useRenderableMediaUrl(src);
21-
const mediaSrc = resolvedSrc ?? src;
2219

2320
const useUniformIcons = uniformIconsSetting && uniformIcons === true;
2421
const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined;
@@ -28,13 +25,13 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
2825
setImage(evt.currentTarget);
2926
};
3027

31-
const isBlobUrl = mediaSrc.startsWith('blob:');
28+
const isBlobUrl = src.startsWith('blob:');
3229

3330
return (
3431
<FoldsAvatarImage
3532
className={css.RoomAvatar}
3633
style={{ backgroundColor: useUniformIcons ? normalizedBg : undefined }}
37-
src={mediaSrc}
34+
src={src}
3835
crossOrigin={isBlobUrl ? undefined : 'anonymous'}
3936
alt={alt}
4037
loading="lazy"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const media = vi.hoisted(() => ({
5+
useRenderableMediaUrl: vi.fn<(url: string | undefined) => string | undefined>(),
6+
}));
7+
8+
vi.mock('$hooks/useRenderableMediaUrl', () => media);
9+
10+
const RAW_SRC = 'https://example.org/_matrix/client/v1/media/thumbnail/example.org/avatar';
11+
12+
describe('RoomAvatar', () => {
13+
beforeEach(() => {
14+
vi.resetModules();
15+
media.useRenderableMediaUrl.mockReset();
16+
});
17+
18+
it('shows the image once the resolved url arrives after a failed raw request', async () => {
19+
media.useRenderableMediaUrl.mockReturnValue(undefined);
20+
const { RoomAvatar } = await import('./RoomAvatar');
21+
22+
const { rerender } = render(
23+
<RoomAvatar roomId="!room:example.org" src={RAW_SRC} renderFallback={() => 'RM'} />
24+
);
25+
26+
fireEvent.error(screen.getByRole('img'));
27+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
28+
29+
media.useRenderableMediaUrl.mockReturnValue('blob:resolved-avatar');
30+
rerender(<RoomAvatar roomId="!room:example.org" src={RAW_SRC} renderFallback={() => 'RM'} />);
31+
32+
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:resolved-avatar');
33+
});
34+
});

src/app/components/room-avatar/RoomAvatar.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getRoomStandaloneIconComponent,
1313
} from '$components/icons/roomIcons';
1414
import colorMXID from '$utils/colorMXID';
15+
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
1516
import * as css from './RoomAvatar.css';
1617
import { AvatarImage } from './AvatarImage';
1718

@@ -25,12 +26,14 @@ type RoomAvatarProps = {
2526

2627
export function RoomAvatar({ roomId, src, alt, renderFallback, uniformIcons }: RoomAvatarProps) {
2728
const [error, setError] = useState(false);
29+
const resolvedSrc = useRenderableMediaUrl(src);
30+
const mediaSrc = resolvedSrc ?? src;
2831

2932
useEffect(() => {
3033
setError(false);
31-
}, [src]);
34+
}, [mediaSrc]);
3235

33-
if (!src || error) {
36+
if (!mediaSrc || error) {
3437
return (
3538
<AvatarFallback
3639
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
@@ -42,7 +45,12 @@ export function RoomAvatar({ roomId, src, alt, renderFallback, uniformIcons }: R
4245
}
4346

4447
return (
45-
<AvatarImage src={src} alt={alt} uniformIcons={uniformIcons} onError={() => setError(true)} />
48+
<AvatarImage
49+
src={mediaSrc}
50+
alt={alt}
51+
uniformIcons={uniformIcons}
52+
onError={() => setError(true)}
53+
/>
4654
);
4755
}
4856

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const media = vi.hoisted(() => ({
5+
useRenderableMediaUrl: vi.fn<(url: string | undefined) => string | undefined>(),
6+
}));
7+
8+
vi.mock('$hooks/useRenderableMediaUrl', () => media);
9+
10+
const RAW_SRC = 'https://example.org/_matrix/client/v1/media/thumbnail/example.org/avatar';
11+
12+
describe('UserAvatar', () => {
13+
beforeEach(() => {
14+
vi.resetModules();
15+
media.useRenderableMediaUrl.mockReset();
16+
});
17+
18+
it('shows the image once the resolved url arrives after a failed raw request', async () => {
19+
media.useRenderableMediaUrl.mockReturnValue(undefined);
20+
const { UserAvatar } = await import('./UserAvatar');
21+
22+
const { rerender } = render(
23+
<UserAvatar userId="@user:example.org" src={RAW_SRC} renderFallback={() => 'US'} />
24+
);
25+
26+
fireEvent.error(screen.getByRole('img'));
27+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
28+
29+
media.useRenderableMediaUrl.mockReturnValue('blob:resolved-avatar');
30+
rerender(<UserAvatar userId="@user:example.org" src={RAW_SRC} renderFallback={() => 'US'} />);
31+
32+
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:resolved-avatar');
33+
});
34+
});

src/app/components/user-avatar/UserAvatar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@ export function UserAvatar({
2929
}: UserAvatarProps) {
3030
const [error, setError] = useState(false);
3131
const resolvedSrc = useRenderableMediaUrl(src);
32+
const mediaSrc = resolvedSrc ?? src;
3233

3334
useEffect(() => {
3435
setError(false);
35-
}, [src]);
36+
}, [mediaSrc]);
3637

37-
if (!src || error) {
38+
if (!mediaSrc || error) {
3839
return (
3940
<AvatarFallback
4041
style={{
@@ -51,7 +52,7 @@ export function UserAvatar({
5152
return (
5253
<AvatarImage
5354
className={classNames(css.UserAvatar, className)}
54-
src={resolvedSrc ?? src}
55+
src={mediaSrc}
5556
alt={alt}
5657
loading="lazy"
5758
decoding="async"

src/app/hooks/useRenderableMediaUrl.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,22 @@ describe('useRenderableMediaUrl', () => {
236236
expect(tauriApi.convertFileSrc).not.toHaveBeenCalled();
237237
});
238238

239+
it('drops the previous loopback url when the media source goes away under Tauri', async () => {
240+
tauriApi.isTauri.mockReturnValue(true);
241+
const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl');
242+
243+
const { result, rerender } = renderHook(
244+
({ url }: { url: string | undefined }) => useRenderableMediaUrl(url),
245+
{ initialProps: { url: 'https://example.org/banner.png' as string | undefined } }
246+
);
247+
248+
await waitFor(() => expect(result.current).toBe(LOOPBACK_URL));
249+
250+
rerender({ url: undefined });
251+
252+
expect(result.current).toBeUndefined();
253+
});
254+
239255
it('passes through non-authenticated URLs unchanged under Tauri', async () => {
240256
tauriApi.isTauri.mockReturnValue(true);
241257
const { useRenderableMediaUrl } = await import('./useRenderableMediaUrl');

src/app/hooks/useRenderableMediaUrl.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,20 +183,21 @@ export function useRenderableMediaUrl(url: string | undefined): string | undefin
183183
);
184184
const protocolUrl = tauri ? (rewriteAuthenticatedMediaUrl(url ?? null) ?? undefined) : undefined;
185185
// A settled entry resolves synchronously, so a repeated avatar never flashes a fallback.
186-
const [loopbackUrl, setLoopbackUrl] = useState<string | undefined>(() =>
187-
tauri && protocolUrl ? loopbackCache.get(protocolUrl)?.url : undefined
188-
);
186+
const [loopbackState, setLoopbackState] = useState<{ source?: string; url?: string }>(() => ({
187+
source: protocolUrl,
188+
url: tauri && protocolUrl ? loopbackCache.get(protocolUrl)?.url : undefined,
189+
}));
189190

190191
useEffect(() => {
191192
if (!tauri || !protocolUrl) return undefined;
192193
const entry = resolveLoopbackUrl(protocolUrl);
193194
if (entry.url) {
194-
setLoopbackUrl(entry.url);
195+
setLoopbackState({ source: protocolUrl, url: entry.url });
195196
return undefined;
196197
}
197198
let cancelled = false;
198199
void entry.promise.then((resolved) => {
199-
if (!cancelled) setLoopbackUrl(resolved);
200+
if (!cancelled) setLoopbackState({ source: protocolUrl, url: resolved });
200201
});
201202
return () => {
202203
cancelled = true;
@@ -270,7 +271,9 @@ export function useRenderableMediaUrl(url: string | undefined): string | undefin
270271
if (tauri) {
271272
// No protocolUrl fallback while resolving: resolveLoopbackUrl already degrades to it,
272273
// and handing out the custom-scheme URL first would fail a media element.
273-
return loopbackUrl;
274+
if (!protocolUrl) return undefined;
275+
if (loopbackState.source === protocolUrl) return loopbackState.url;
276+
return loopbackCache.get(protocolUrl)?.url;
274277
}
275278

276279
if (!needsBlob || usesExistingObjectUrl) {

0 commit comments

Comments
 (0)