Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-msc4466-profile-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add MSC4466 profile change propagation controls for homeservers that advertise support.
91 changes: 81 additions & 10 deletions src/app/features/settings/account/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -59,9 +66,11 @@ type PronounSet = {
type ProfileProps = {
profile: UserProfile;
userId: string;
propagateTo?: ProfileChangePropagation;
};
function ProfileAvatar({ profile, userId }: Readonly<ProfileProps>) {
function ProfileAvatar({ profile, userId, propagateTo }: Readonly<ProfileProps>) {
const mx = useMatrixClient();
const setGlobalProfiles = useSetAtom(profilesCacheAtom);
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
Expand All @@ -86,16 +95,24 @@ function ProfileAvatar({ profile, userId }: Readonly<ProfileProps>) {
}, []);

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);
};

Expand Down Expand Up @@ -386,16 +403,26 @@ function ProfileBanner({ profile }: Readonly<Pick<ProfileProps, 'profile'>>) {
);
}

function ProfileDisplayName({ profile, userId }: Readonly<ProfileProps>) {
function ProfileDisplayName({ profile, userId, propagateTo }: Readonly<ProfileProps>) {
const mx = useMatrixClient();
const setGlobalProfiles = useSetAtom(profilesCacheAtom);
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;

const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
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;

Expand Down Expand Up @@ -478,6 +505,42 @@ function ProfileDisplayName({ profile, userId }: Readonly<ProfileProps>) {
);
}

function ProfileChangePropagationSetting({ disabled }: { disabled: boolean }) {
const [profileChangePropagation, setProfileChangePropagation] = useSetting(
settingsAtom,
'profileChangePropagation'
);
const options: SettingMenuOption<ProfileChangePropagation>[] = [
{ value: 'all', label: 'All rooms' },
{ value: 'unchanged', label: 'Unchanged rooms' },
{ value: 'none', label: 'Global only' },
];

return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
style={{ opacity: disabled ? 0.5 : 1 }}
>
<SettingTile
title="Profile change propagation"
focusId="profile-change-propagation"
description="Choose where global name and avatar changes are applied."
after={
<SettingMenuSelector
value={profileChangePropagation}
options={options}
onSelect={setProfileChangePropagation}
disabled={disabled}
/>
}
/>
</SequenceCard>
);
}

function ProfileExtended({ profile, userId }: Readonly<ProfileProps>) {
const mx = useMatrixClient();
const setGlobalProfiles = useSetAtom(profilesCacheAtom);
Expand Down Expand Up @@ -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 (
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
Expand All @@ -760,16 +830,17 @@ export function Profile() {
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileAvatar userId={userId} profile={profile} propagateTo={propagateTo} />
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileDisplayName userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} propagateTo={propagateTo} />
</SequenceCard>
<ProfileChangePropagationSetting disabled={!supportsProfileChangePropagation} />
</Box>
<ProfileExtended userId={userId} profile={profile} />
<AnimalCosmetics userId={userId} profile={profile} />
Expand Down
15 changes: 3 additions & 12 deletions src/app/hooks/useCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]: {
Expand Down Expand Up @@ -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]: {
Expand Down
5 changes: 5 additions & 0 deletions src/app/state/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -212,6 +213,7 @@ export interface Settings {
pkCompat: boolean;
pmpProxying: boolean;
mentionInReplies: boolean;
profileChangePropagation: ProfileChangePropagation;
showPersonaSetting: boolean;
closeFoldersByDefault: boolean;
perRoomShowRoomIcon: PerRoomShowRoomIcon[];
Expand Down Expand Up @@ -376,6 +378,7 @@ export const defaultSettings: Settings = {
pkCompat: false,
pmpProxying: false,
mentionInReplies: true,
profileChangePropagation: 'unchanged',
showPersonaSetting: false,
closeFoldersByDefault: false,
perRoomShowRoomIcon: [],
Expand Down Expand Up @@ -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':
Expand Down
51 changes: 51 additions & 0 deletions src/app/utils/msc4466.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> => setProfileInfoWithPropagation(mx, 'displayname', displayName, propagateTo);

export const setAvatarUrlWithPropagation = (
mx: MatrixClient,
avatarUrl: string,
propagateTo?: ProfileChangePropagation
): Promise<void> => setProfileInfoWithPropagation(mx, 'avatar_url', avatarUrl, propagateTo);
24 changes: 24 additions & 0 deletions src/app/utils/roomMemberProfile.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
}),
]);
}
6 changes: 6 additions & 0 deletions src/unstable/prefixes/msc/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading