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) {
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/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;
};
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
-
+
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'
+ );