From ca458df5b23b4f04736233a81859ed0b1d258a4b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 8 Aug 2026 14:23:24 +0200 Subject: [PATCH 1/4] fix(menu): gate compact menus on pointer type, not viewport width --- src/app/hooks/useScreenSize.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useScreenSize.ts b/src/app/hooks/useScreenSize.ts index 64d2883e57..f45f51a300 100644 --- a/src/app/hooks/useScreenSize.ts +++ b/src/app/hooks/useScreenSize.ts @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useState } from 'react'; +import { createContext, useCallback, useContext, useState, useSyncExternalStore } from 'react'; import { useElementSizeObserver } from './useElementSizeObserver'; const TABLET_BREAKPOINT = 1124; @@ -41,8 +41,25 @@ export const useScreenSizeContext = (): ScreenSize => { return screenSize; }; -/** Tablet as well as Mobile, for touch presentation rather than available width. */ +const coarsePointerQuery = () => globalThis.matchMedia?.('(pointer: coarse)'); + +const subscribeCoarsePointer = (onChange: () => void) => { + const query = coarsePointerQuery(); + query?.addEventListener('change', onChange); + return () => query?.removeEventListener('change', onChange); +}; + +const getCoarsePointer = () => coarsePointerQuery()?.matches ?? false; + +/** Mobile, or tablet width with a touch pointer — not a narrow desktop window. */ export const useCompactLayout = (): boolean => { const screenSize = useContext(ScreenSizeContext); - return screenSize !== null && screenSize !== ScreenSize.Desktop; + const coarsePointer = useSyncExternalStore( + subscribeCoarsePointer, + getCoarsePointer, + getCoarsePointer + ); + + if (screenSize === ScreenSize.Mobile) return true; + return screenSize === ScreenSize.Tablet && coarsePointer; }; From 93076b21f8697b4a449a4dfda9a51215ba731728 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 8 Aug 2026 14:26:17 +0200 Subject: [PATCH 2/4] refactor(composer): only load the persona catalog when proxying is enabled --- src/app/features/room/composerMessage.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index aaefda3ceb..77d156cd3c 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -127,7 +127,6 @@ const applyPerMessageProfileFallback = ( content.formatted_body = htmlPrefix + content.formatted_body; } else { // we don't have a formatted body, but the fallback needs one - // set before content.body so we don't double fallback content.format = 'org.matrix.custom.html'; const escapedBody = sanitizeText(bodyWithoutFallback).replaceAll('\n', '
'); content.formatted_body = `${htmlPrefix}${escapedBody}`; @@ -223,12 +222,11 @@ export async function buildOutgoingMessage( // PluralKit-style proxy wrappers must be stripped before building `content`, otherwise // the wrapper itself gets sent verbatim. const catalog = new ProfileCatalog(mx); - const personas = await catalog.list({ migrate: false }); let proxiedPerMessageProfile: PerMessageProfileMsc4461 | undefined; let proxyStripped = false; if (pmpProxyingEnable) { const proxy = resolvePersonaProxy( - personas, + await catalog.list({ migrate: false }), toPlainText(serializedChildren, true, false, nicknameReplacement).trim() ); if (proxy) { From ecb46f8af3d18fe14b0a704a75dd7955329e7d65 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 8 Aug 2026 14:32:38 +0200 Subject: [PATCH 3/4] fix(tauri): save JSON exports through the native file dialog --- .../Persona/PerMessageProfileOverview.tsx | 4 +-- src/app/features/settings/general/General.tsx | 5 ++-- src/app/utils/common.ts | 11 ------- src/app/utils/download.test.ts | 29 ++++++++++++++++++- src/app/utils/download.ts | 10 +++++++ 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/app/features/settings/Persona/PerMessageProfileOverview.tsx b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx index 1cfa1faffd..cf12cf60ee 100644 --- a/src/app/features/settings/Persona/PerMessageProfileOverview.tsx +++ b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx @@ -17,7 +17,7 @@ import { MATRIX_UNSTABLE_COLORS, MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, } from '$unstable/prefixes'; -import { downloadJsonFile } from '$app/utils/common'; +import { downloadJsonFile } from '$app/utils/download'; import { selectFile } from '$app/utils/dom'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; import { AsyncError } from '$components/AsyncError'; @@ -96,7 +96,7 @@ export function PerMessageProfileOverview({ useCallback(async () => { const personas = await new ProfileCatalog(mx).list(); const data = { personas }; - downloadJsonFile(JSON.stringify(data), 'persona'); + await downloadJsonFile(JSON.stringify(data), 'persona'); }, [mx]) ); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29fc20375a..4d63cbc9db 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -55,7 +55,6 @@ import { useSetting } from '$state/hooks/settings'; import type { EditorButtonId } from '$state/settings'; import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings'; import { SettingTile, SettingToggle } from '$components/setting-tile'; -import { downloadJsonFile } from '$utils/common'; import { getDebugLogger } from '$utils/debugLogger'; import { KeySymbol } from '$utils/key-symbol'; import { isDesktopTauri, isMacOS, isMobileOrTablet, isMobileTauri } from '$utils/platform'; @@ -66,7 +65,7 @@ import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSe import { sanitizeDiagnosticsLogs } from '$utils/sentryScrubbers'; import { diagnosticCaptureActiveAtom } from '$state/debugLogger'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; -import { saveFileToDevice } from '$utils/download'; +import { downloadJsonFile, saveFileToDevice } from '$utils/download'; import { CallSoundSettings } from './CallSoundSettings'; type DateHintProps = { @@ -1353,7 +1352,7 @@ function DiagnosticsAndPrivacy() { setDiagnosticsState('error'); return; } - downloadJsonFile(sanitizedLogs, 'sable-web-diagnostics'); + await downloadJsonFile(sanitizedLogs, 'sable-web-diagnostics'); } setDiagnosticsState('success'); setCaptureCompleted(false); diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index c4c02430ce..e8eb408ddc 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -135,17 +135,6 @@ export const suffixRename = (name: string, validator: (newName: string) => boole export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-'); -/** Trigger a browser download of a JSON file. */ -export const downloadJsonFile = (content: string, fileNamePrefix: string): void => { - const blob = new Blob([content], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${fileNamePrefix}-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); -}; - export const splitWithSpace = (content: string): string[] => { const trimmedContent = content.trim(); if (trimmedContent === '') return []; diff --git a/src/app/utils/download.test.ts b/src/app/utils/download.test.ts index d1fd578e93..79848ca9bf 100644 --- a/src/app/utils/download.test.ts +++ b/src/app/utils/download.test.ts @@ -3,7 +3,7 @@ import FileSaver from 'file-saver'; import { invoke, isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; import { showToast } from '$state/toast'; -import { saveFileToDevice, saveMediaToGallery } from './download'; +import { downloadJsonFile, saveFileToDevice, saveMediaToGallery } from './download'; const mocks = vi.hoisted(() => ({ androidFs: { @@ -123,6 +123,33 @@ describe('saveFileToDevice', () => { }); }); +describe('downloadJsonFile', () => { + it('saves through the native desktop command instead of an anchor click', async () => { + vi.mocked(osType).mockReturnValue('linux'); + + const result = await downloadJsonFile('{"a":1}', 'persona'); + + expect(result).toBe('saved'); + expect(invoke).toHaveBeenCalledWith('save_download', { + filename: expect.stringMatching(/^persona-\d+\.json$/), + bytes: expect.any(Array), + }); + expect(FileSaver.saveAs).not.toHaveBeenCalled(); + }); + + it('routes Android exports to the public Downloads directory', async () => { + const result = await downloadJsonFile('{"a":1}', 'persona'); + + expect(result).toBe('saved'); + expect(androidFs.createNewPublicFile).toHaveBeenCalledWith( + 'Download', + expect.stringMatching(/^persona-\d+\.json$/), + 'application/json', + { isPending: true, requestPermission: true } + ); + }); +}); + describe('saveMediaToGallery', () => { it('saves Android images to Pictures through the public image API', async () => { await saveMediaToGallery(new Blob(['data']), 'photo.png', 'image/png'); diff --git a/src/app/utils/download.ts b/src/app/utils/download.ts index 380f37b0e1..cd15781f53 100644 --- a/src/app/utils/download.ts +++ b/src/app/utils/download.ts @@ -185,3 +185,13 @@ export async function saveFileToDevice( FileSaver.saveAs(input, filename); return 'saved'; } + +export const downloadJsonFile = ( + content: string, + fileNamePrefix: string +): Promise<'saved' | 'cancelled' | 'failed'> => + saveFileToDevice( + new Blob([content], { type: 'application/json' }), + `${fileNamePrefix}-${Date.now()}.json`, + 'application/json' + ); From 94f6c38f28a88e16ed0db099e535fcccb4353928 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 8 Aug 2026 14:39:45 +0200 Subject: [PATCH 4/4] fix(sidebar): keep the add-space menu icons on the label row --- src/app/pages/client/sidebar/CreateTab.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx index 341d137cdd..cc78cde71a 100644 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ b/src/app/pages/client/sidebar/CreateTab.tsx @@ -7,7 +7,6 @@ import { useNavigate } from 'react-router-dom'; import { SidebarAvatar, SidebarItemLeft, SidebarItemTooltip } from '$components/sidebar'; import { stopPropagation } from '$utils/keyboard'; import { SequenceCard } from '$components/sequence-card'; -import { SettingTile } from '$components/setting-tile'; import { ContainerColor } from '$styles/ContainerColor.css'; import { encodeSearchParamValueArray, @@ -130,9 +129,10 @@ export function CreateTab() { type="button" onClick={handleCreateSpace} > - + + {composerIcon(SquaresFour)} Create a New Space - + - + + {composerIcon(Link)} Join Community via Address - + - + + {composerIcon(UsersThree)} Explore Recommended Spaces - +