diff --git a/.changeset/add-msc4466-profile-propagation.md b/.changeset/add-msc4466-profile-propagation.md new file mode 100644 index 0000000000..6cc996b55f --- /dev/null +++ b/.changeset/add-msc4466-profile-propagation.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add MSC4466 profile change propagation controls for homeservers that advertise support. diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index ef7db0f9ca..0f756b967e 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -20,6 +20,8 @@ import { composerIcon, menuIcon, Star, Sun, X } from '$components/icons/phosphor import FocusTrap from 'focus-trap-react'; import { useSetAtom } from 'jotai'; import { SequenceCard } from '$components/sequence-card'; +import type { SettingMenuOption } from '$components/setting-menu-selector'; +import { SettingMenuSelector } from '$components/setting-menu-selector'; import { SettingTile } from '$components/setting-tile'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { UserProfile, MSC4440Bio } from '$hooks/useUserProfile'; @@ -42,7 +44,12 @@ import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { useUserPresence } from '$hooks/useUserPresence'; +import { useSpecVersions } from '$hooks/useSpecVersions'; +import { useSetting } from '$state/hooks/settings'; +import type { ProfileChangePropagation } from '$state/settings'; +import { settingsAtom } from '$state/settings'; import type { MSC1767Text } from '$types/matrix/common'; +import { setAvatarUrlWithPropagation, setDisplayNameWithPropagation } from '$utils/msc4466'; import { TimezoneEditor } from './TimezoneEditor'; import { PronounEditor } from './PronounEditor'; import { BioEditor } from './BioEditor'; @@ -59,9 +66,11 @@ type PronounSet = { type ProfileProps = { profile: UserProfile; userId: string; + propagateTo?: ProfileChangePropagation; }; -function ProfileAvatar({ profile, userId }: Readonly) { +function ProfileAvatar({ profile, userId, propagateTo }: Readonly) { const mx = useMatrixClient(); + const setGlobalProfiles = useSetAtom(profilesCacheAtom); const useAuthentication = useMediaAuthentication(); const capabilities = useCapabilities(); const [alertRemove, setAlertRemove] = useState(false); @@ -86,16 +95,24 @@ function ProfileAvatar({ profile, userId }: Readonly) { }, []); const handleUploaded = useCallback( - (upload: UploadSuccess) => { + async (upload: UploadSuccess) => { const { mxc } = upload; - mx.setAvatarUrl(mxc); + await setAvatarUrlWithPropagation(mx, mxc, propagateTo); + setGlobalProfiles((prev) => ({ + ...prev, + [userId]: { ...prev[userId], avatarUrl: mxc }, + })); handleRemoveUpload(); }, - [mx, handleRemoveUpload] + [mx, userId, propagateTo, setGlobalProfiles, handleRemoveUpload] ); - const handleRemoveAvatar = () => { - mx.setAvatarUrl(''); + const handleRemoveAvatar = async () => { + await setAvatarUrlWithPropagation(mx, '', propagateTo); + setGlobalProfiles((prev) => ({ + ...prev, + [userId]: { ...prev[userId], avatarUrl: undefined }, + })); setAlertRemove(false); }; @@ -386,8 +403,9 @@ function ProfileBanner({ profile }: Readonly>) { ); } -function ProfileDisplayName({ profile, userId }: Readonly) { +function ProfileDisplayName({ profile, userId, propagateTo }: Readonly) { const mx = useMatrixClient(); + const setGlobalProfiles = useSetAtom(profilesCacheAtom); const capabilities = useCapabilities(); const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; @@ -395,7 +413,16 @@ function ProfileDisplayName({ profile, userId }: Readonly) { const [displayName, setDisplayName] = useState(defaultDisplayName); const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) + useCallback( + async (name: string) => { + await setDisplayNameWithPropagation(mx, name, propagateTo); + setGlobalProfiles((prev) => ({ + ...prev, + [userId]: { ...prev[userId], displayName: name }, + })); + }, + [mx, userId, propagateTo, setGlobalProfiles] + ) ); const changingDisplayName = changeState.status === AsyncStatus.Loading; @@ -478,6 +505,42 @@ function ProfileDisplayName({ profile, userId }: Readonly) { ); } +function ProfileChangePropagationSetting({ disabled }: { disabled: boolean }) { + const [profileChangePropagation, setProfileChangePropagation] = useSetting( + settingsAtom, + 'profileChangePropagation' + ); + const options: SettingMenuOption[] = [ + { value: 'all', label: 'All rooms' }, + { value: 'unchanged', label: 'Unchanged rooms' }, + { value: 'none', label: 'Global only' }, + ]; + + return ( + + + } + /> + + ); +} + function ProfileExtended({ profile, userId }: Readonly) { const mx = useMatrixClient(); const setGlobalProfiles = useSetAtom(profilesCacheAtom); @@ -742,6 +805,13 @@ export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; const profile = useUserProfile(userId); + const { unstable_features: unstableFeatures } = useSpecVersions(); + const supportsProfileChangePropagation = + unstableFeatures?.[prefix.MATRIX_UNSTABLE_MSC4466_FEATURE] === true; + const [profileChangePropagation] = useSetting(settingsAtom, 'profileChangePropagation'); + const propagateTo: ProfileChangePropagation | undefined = supportsProfileChangePropagation + ? profileChangePropagation + : undefined; return ( @@ -760,7 +830,7 @@ export function Profile() { direction="Column" gap="400" > - + - + + diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 9c9451be8a..bdadcdb5aa 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -32,6 +32,7 @@ import { removeRoomIdFromMDirect, } from '$utils/matrix'; import { getStateEvent } from '$utils/room'; +import { setOwnRoomMemberProfile } from '$utils/roomMemberProfile'; import { splitWithSpace } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -507,12 +508,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { } else { withDisplay.displayname = nick; } - await mx.sendStateEvent( - room.roomId, - EventType.RoomMember, - updatedContent, - mx.getSafeUserId() - ); + await setOwnRoomMemberProfile(mx, room, updatedContent); }, }, [Command.AddPerMessageProfileToAccount]: { @@ -676,12 +672,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { (updatedContent as RoomMemberEventContent & { avatar_url?: string }).avatar_url = trimmed; } - await mx.sendStateEvent( - room.roomId, - EventType.RoomMember, - updatedContent, - mx.getSafeUserId() - ); + await setOwnRoomMemberProfile(mx, room, updatedContent); }, }, [Command.ConvertToDm]: { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d918344093..09d3b1a229 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -67,6 +67,7 @@ export type RenderUserCardsMode = 'both' | 'light' | 'dark' | 'none'; /** Where to use crisp nearest-neighbor (pixelated) image scaling. */ export type PixelatedImageRenderingMode = 'always' | 'smart' | 'never'; +export type ProfileChangePropagation = 'all' | 'unchanged' | 'none'; export function isPixelatedRendering( mode: PixelatedImageRenderingMode, @@ -212,6 +213,7 @@ export interface Settings { pkCompat: boolean; pmpProxying: boolean; mentionInReplies: boolean; + profileChangePropagation: ProfileChangePropagation; showPersonaSetting: boolean; closeFoldersByDefault: boolean; perRoomShowRoomIcon: PerRoomShowRoomIcon[]; @@ -376,6 +378,7 @@ export const defaultSettings: Settings = { pkCompat: false, pmpProxying: false, mentionInReplies: true, + profileChangePropagation: 'unchanged', showPersonaSetting: false, closeFoldersByDefault: false, perRoomShowRoomIcon: [], @@ -625,6 +628,8 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { : undefined; case 'pixelatedImageRendering': return val === 'always' || val === 'smart' || val === 'never' ? val : undefined; + case 'profileChangePropagation': + return val === 'all' || val === 'unchanged' || val === 'none' ? val : undefined; case 'iconCompactSizePx': case 'iconInlineSizePx': case 'iconToolbarSizePx': diff --git a/src/app/utils/msc4466.ts b/src/app/utils/msc4466.ts new file mode 100644 index 0000000000..a6b9ede8eb --- /dev/null +++ b/src/app/utils/msc4466.ts @@ -0,0 +1,51 @@ +import type { MatrixClient } from '$types/matrix-sdk'; +import { Method, UserEvent } from '$types/matrix-sdk'; +import { MATRIX_UNSTABLE_MSC4466_PROPAGATE_TO } from '$unstable/prefixes'; +import type { ProfileChangePropagation } from '$state/settings'; + +type ProfileKey = 'avatar_url' | 'displayname'; + +async function setProfileInfoWithPropagation( + mx: MatrixClient, + key: ProfileKey, + value: string, + propagateTo?: ProfileChangePropagation +): Promise { + if (propagateTo === undefined) { + if (key === 'displayname') await mx.setDisplayName(value); + else await mx.setAvatarUrl(value); + return; + } + + const userId = mx.getSafeUserId(); + await mx.http.authedRequest( + Method.Put, + `/profile/${encodeURIComponent(userId)}/${key}`, + { [MATRIX_UNSTABLE_MSC4466_PROPAGATE_TO]: propagateTo }, + { [key]: value } + ); + + // Match the SDK setters by immediately updating the local profile cache. + const user = mx.getUser(userId); + if (!user) return; + + if (key === 'displayname') { + user.displayName = value; + user.emit(UserEvent.DisplayName, user.events.presence, user); + } else { + user.avatarUrl = value; + user.emit(UserEvent.AvatarUrl, user.events.presence, user); + } +} + +export const setDisplayNameWithPropagation = ( + mx: MatrixClient, + displayName: string, + propagateTo?: ProfileChangePropagation +): Promise => setProfileInfoWithPropagation(mx, 'displayname', displayName, propagateTo); + +export const setAvatarUrlWithPropagation = ( + mx: MatrixClient, + avatarUrl: string, + propagateTo?: ProfileChangePropagation +): Promise => setProfileInfoWithPropagation(mx, 'avatar_url', avatarUrl, propagateTo); diff --git a/src/app/utils/roomMemberProfile.ts b/src/app/utils/roomMemberProfile.ts new file mode 100644 index 0000000000..6c92b3a507 --- /dev/null +++ b/src/app/utils/roomMemberProfile.ts @@ -0,0 +1,24 @@ +import type { MatrixClient, Room, RoomMemberEventContent } from '$types/matrix-sdk'; +import { EventType, MatrixEvent } from '$types/matrix-sdk'; + +/** Send an own-membership profile update and immediately reflect the accepted state locally. */ +export async function setOwnRoomMemberProfile( + mx: MatrixClient, + room: Room, + content: RoomMemberEventContent +): Promise { + const userId = mx.getSafeUserId(); + const response = await mx.sendStateEvent(room.roomId, EventType.RoomMember, content, userId); + + room.currentState.setStateEvents([ + new MatrixEvent({ + event_id: response.event_id, + origin_server_ts: Date.now(), + room_id: room.roomId, + sender: userId, + state_key: userId, + type: EventType.RoomMember, + content, + }), + ]); +} diff --git a/src/unstable/prefixes/msc/profile.ts b/src/unstable/prefixes/msc/profile.ts index 885f4f79f7..65444b2987 100644 --- a/src/unstable/prefixes/msc/profile.ts +++ b/src/unstable/prefixes/msc/profile.ts @@ -14,3 +14,9 @@ export const MATRIX_UNSTABLE_PROFILE_BIOGRAPHY_PROPERTY_NAME = 'gay.fomx.biograp export const MATRIX_UNSTABLE_PROFILE_TIMEZONE_PROPERTY_NAME = 'us.cloke.msc4175.tz'; export const MATRIX_STABLE_PROFILE_TIMEZONE_PROPERTY_NAME = 'm.tz'; + +/** + * Unstable feature and query parameter names for MSC4466 profile change propagation. + */ +export const MATRIX_UNSTABLE_MSC4466_FEATURE = 'computer.gingershaped.msc4466'; +export const MATRIX_UNSTABLE_MSC4466_PROPAGATE_TO = 'computer.gingershaped.msc4466.propagate_to';