Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
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:
10
src/app/utils/appVersion.ts
Normal file
10
src/app/utils/appVersion.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
192
src/app/utils/profileFields.ts
Normal file
192
src/app/utils/profileFields.ts
Normal 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);
|
||||
}
|
||||
111
src/app/utils/registerDesktopMediaSaver.ts
Normal file
111
src/app/utils/registerDesktopMediaSaver.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user