Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s

Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
This commit is contained in:
2026-08-23 08:06:48 +10:00
parent 9887903f49
commit e65a516350
35 changed files with 8773 additions and 1326 deletions

View File

@@ -0,0 +1,10 @@
import { getVersion } from '@tauri-apps/api/app';
/** Returns the running app version (Electron shim or Tauri). */
export async function getAppVersion(): Promise<string> {
try {
return await getVersion();
} catch {
return 'unknown';
}
}

View File

@@ -0,0 +1,192 @@
import { MatrixClient, Room } from 'matrix-js-sdk';
import { ThemeKind } from '../hooks/useTheme';
/** MSC4522 stable profile field for username colors */
export const PROFILE_KEY_COLOR_PREFERENCE_STABLE = 'm.color_preference';
/** MSC4522 unstable profile field for username colors */
export const PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE = 'eu.she-a.color';
/** MSC4427 unstable profile field for banner */
export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
/** MSC4427 stable profile field for banner */
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
export type ColorPreference = {
on_dark?: string;
on_light?: string;
};
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
export function isValidHexColor(color: string): boolean {
return HEX_COLOR_RE.test(color);
}
export function parseColorPreference(value: unknown): ColorPreference | undefined {
if (!value || typeof value !== 'object') return undefined;
const obj = value as Record<string, unknown>;
const on_dark = typeof obj.on_dark === 'string' && isValidHexColor(obj.on_dark) ? obj.on_dark : undefined;
const on_light = typeof obj.on_light === 'string' && isValidHexColor(obj.on_light) ? obj.on_light : undefined;
if (!on_dark && !on_light) return undefined;
return { on_dark, on_light };
}
export function extractColorPreferenceFromProfile(profile: Record<string, unknown>): ColorPreference | undefined {
const stable = parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_STABLE]);
if (stable) return stable;
return parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE]);
}
export function resolveColorForTheme(
preference: ColorPreference | undefined,
themeKind: ThemeKind
): string | undefined {
if (!preference) return undefined;
if (themeKind === ThemeKind.Dark) {
return preference.on_dark ?? preference.on_light;
}
return preference.on_light ?? preference.on_dark;
}
export function hasColorPreference(preference: ColorPreference | undefined): boolean {
return Boolean(preference?.on_dark || preference?.on_light);
}
export function extractBannerUrlFromProfile(profile: Record<string, unknown>): string | undefined {
const stable = profile[PROFILE_KEY_BANNER_URL_STABLE];
if (typeof stable === 'string' && stable.startsWith('mxc://')) return stable;
const unstable = profile[PROFILE_KEY_BANNER_URL_UNSTABLE];
if (typeof unstable === 'string' && unstable.startsWith('mxc://')) return unstable;
return undefined;
}
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
if (await mx.isVersionSupported('v1.16')) {
return PROFILE_KEY_BANNER_URL_STABLE;
}
return PROFILE_KEY_BANNER_URL_UNSTABLE;
}
export async function loadBannerUrl(mx: MatrixClient, userId: string): Promise<string | undefined> {
if (await mx.doesServerSupportExtendedProfiles()) {
try {
const profile = await mx.getExtendedProfile(userId);
return extractBannerUrlFromProfile(profile);
} catch {
// fall through
}
}
try {
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
return extractBannerUrlFromProfile(profile);
} catch {
return undefined;
}
}
export async function saveBannerUrl(mx: MatrixClient, bannerMxc: string): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getBannerUrlProfileKey(mx);
await mx.setExtendedProfileProperty(key, bannerMxc);
const userId = mx.getUserId();
if (userId) clearBannerCache(userId);
}
export async function deleteBannerUrl(mx: MatrixClient): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getBannerUrlProfileKey(mx);
await mx.deleteExtendedProfileProperty(key);
const userId = mx.getUserId();
if (userId) clearBannerCache(userId);
}
let bannerCacheClearImpl: (userId: string) => void = () => {};
export function registerBannerCacheClear(fn: (userId: string) => void): void {
bannerCacheClearImpl = fn;
}
function clearBannerCache(userId: string): void {
bannerCacheClearImpl(userId);
}
export async function getColorPreferenceProfileKey(mx: MatrixClient): Promise<string> {
if (await mx.isVersionSupported('v1.16')) {
return PROFILE_KEY_COLOR_PREFERENCE_STABLE;
}
return PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE;
}
export async function loadColorPreference(
mx: MatrixClient,
userId: string
): Promise<ColorPreference | undefined> {
if (await mx.doesServerSupportExtendedProfiles()) {
try {
const profile = await mx.getExtendedProfile(userId);
return extractColorPreferenceFromProfile(profile);
} catch {
// fall through to standard profile endpoint
}
}
try {
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
return extractColorPreferenceFromProfile(profile);
} catch {
return undefined;
}
}
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
if (!room) return undefined;
const member = room.getMember(userId);
const content = member?.events.member?.getContent();
if (!content) return undefined;
return extractColorPreferenceFromProfile(content as Record<string, unknown>);
}
export async function saveColorPreference(mx: MatrixClient, preference: ColorPreference): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getColorPreferenceProfileKey(mx);
await mx.setExtendedProfileProperty(key, preference);
const userId = mx.getUserId();
if (userId) clearPreferenceCache(userId);
}
let preferenceCacheClearImpl: (userId: string) => void = () => {};
export function registerPreferenceCacheClear(fn: (userId: string) => void): void {
preferenceCacheClearImpl = fn;
}
function clearPreferenceCache(userId: string): void {
preferenceCacheClearImpl(userId);
}
export async function deleteColorPreference(mx: MatrixClient): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getColorPreferenceProfileKey(mx);
await mx.deleteExtendedProfileProperty(key);
const userId = mx.getUserId();
if (userId) clearPreferenceCache(userId);
}

View File

@@ -0,0 +1,111 @@
import { registerMediaSaver } from './saveMedia';
import { isElectron, isTauri } from './tauri';
type ElectronSaveResult = {
success?: boolean;
canceled?: boolean;
path?: string;
error?: string;
};
type ElectronMediaApi = {
saveFile?: (payload: {
filename: string;
mimeType?: string;
data: Uint8Array;
}) => Promise<ElectronSaveResult>;
};
async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer);
}
function guessFilters(filename: string, mimeType?: string): Array<{ name: string; extensions: string[] }> {
const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : '';
const mime = (mimeType || '').toLowerCase();
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
return [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mime.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) {
return [
{ name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mime.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) {
return [
{ name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mime === 'application/pdf' || ext === 'pdf') {
return [
{ name: 'PDF', extensions: ['pdf'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (ext) {
return [
{ name: ext.toUpperCase(), extensions: [ext] },
{ name: 'All Files', extensions: ['*'] },
];
}
return [{ name: 'All Files', extensions: ['*'] }];
}
async function saveWithElectron(blob: Blob, filename: string): Promise<void> {
const media = (window.electron as { media?: ElectronMediaApi } | undefined)?.media;
if (!media?.saveFile) {
throw new Error('Electron media.saveFile is unavailable');
}
const data = await blobToUint8Array(blob);
const result = await media.saveFile({
filename,
mimeType: blob.type || 'application/octet-stream',
data,
});
if (result?.canceled) return;
if (result?.success === false) {
throw new Error(result.error || 'Failed to save file');
}
}
async function saveWithTauri(blob: Blob, filename: string): Promise<void> {
const { save } = await import('@tauri-apps/plugin-dialog');
const { writeFile } = await import('@tauri-apps/plugin-fs');
const path = await save({
defaultPath: filename,
filters: guessFilters(filename, blob.type),
});
if (!path) return;
const data = await blobToUint8Array(blob);
await writeFile(path, data);
}
/**
* Wire Electron / Tauri native save dialogs into core saveMedia helpers.
* Call once at desktop app startup. No-ops in plain browser.
*/
export function registerDesktopMediaSaver(): void {
if (isElectron()) {
registerMediaSaver(async (blob, filename) => {
await saveWithElectron(blob, filename);
});
return;
}
if (isTauri()) {
registerMediaSaver(async (blob, filename) => {
await saveWithTauri(blob, filename);
});
}
}