diff --git a/.changeset/add_account_wide_persona_toggle_in_persona_picker.md b/.changeset/add_account_wide_persona_toggle_in_persona_picker.md new file mode 100644 index 0000000000..96f4df9113 --- /dev/null +++ b/.changeset/add_account_wide_persona_toggle_in_persona_picker.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Add Account-wide persona toggle in Persona Picker diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e5f53370ef..1f7cd64947 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -145,6 +145,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { convertPerMessageProfileToBeeperFormat, + getCurrentlyUsedPerMessageProfileForAccount, getCurrentlyUsedPerMessageProfileForRoom, } from '$hooks/usePerMessageProfile'; import { @@ -206,7 +207,7 @@ import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { useClientConfig } from '$hooks/useClientConfig'; -import { PersonaPicker } from './persona-picker/PersonaPicker.tsx'; +import { PersonaPicker, type PersonaPickerTab } from './persona-picker/PersonaPicker.tsx'; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -517,6 +518,10 @@ export const RoomInput = forwardRef( const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); // Android back closes the mobile emoji board instead of navigating away. useDismissOnBack(() => setEmojiBoardTab(undefined), emojiBoardTab !== undefined); + const [personaPickerTab, setPersonaPickerTab] = useState( + undefined + ); + const [enableMediaGalleries] = useSetting(settingsAtom, 'enableMediaGalleries'); const [sendIndividualAttachmentAsCaption] = useSetting( settingsAtom, @@ -665,7 +670,9 @@ export const RoomInput = forwardRef( * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. */ - const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); + const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + const perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile; if (perMessageProfile) { contents.forEach((c) => { @@ -1102,7 +1109,10 @@ export const RoomInput = forwardRef( * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. */ - let perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); + const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + let perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile; + if (pmpProxyingEnable) { if (proxiedPerMessageProfile) perMessageProfile = proxiedPerMessageProfile; } @@ -1452,7 +1462,9 @@ export const RoomInput = forwardRef( * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. */ - const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); + const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + const perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile; if (perMessageProfile) { content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = @@ -1867,9 +1879,11 @@ export const RoomInput = forwardRef( )} {pmpPickerEnable && ( )} diff --git a/src/app/features/room/persona-picker/PersonaPicker.css.ts b/src/app/features/room/persona-picker/PersonaPicker.css.ts index 133ac18d12..553e08554e 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.css.ts +++ b/src/app/features/room/persona-picker/PersonaPicker.css.ts @@ -4,6 +4,7 @@ import { color, config, toRem } from 'folds'; export const PersonaPickerMenuItem = style({ backgroundColor: color.Surface.Container, minWidth: toRem(200), + padding: '0', selectors: { '&:hover': { backgroundColor: color.Surface.ContainerHover, diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx index c88a48f0cb..400df94848 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -1,4 +1,9 @@ -import { composerIcon, User as UserIcon } from '$components/icons/phosphor'; +import { + composerIcon, + MagnifyingGlass, + menuIcon, + User as UserIcon, +} from '$components/icons/phosphor'; import { UserAvatar } from '$components/user-avatar/UserAvatar.tsx'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; import { @@ -6,6 +11,8 @@ import { getAllPerMessageProfiles, type PerMessageProfile, setCurrentlyUsedPerMessageProfileIdForRoom, + getCurrentlyUsedPerMessageProfileForAccount, + setCurrentlyUsedPerMessageProfileIdForAccount, } from '$hooks/usePerMessageProfile'; import { stopPropagation } from '$utils/keyboard'; import { mxcUrlToHttp } from '$utils/matrix.ts'; @@ -25,24 +32,48 @@ import { Scroll, Text, toRem, + Badge, } from 'folds'; import type { MatrixClient } from 'matrix-js-sdk'; import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; import * as css from './PersonaPicker.css.ts'; +import { InfoCard } from '$components/info-card/InfoCard.tsx'; +import { InfoIcon } from '@phosphor-icons/react'; + +export enum PersonaPickerTab { + Global = 'Global', + PerRoom = 'PerRoom', +} type PersonaPickerProps = { + tab?: PersonaPickerTab; mx: MatrixClient; roomId: string; suppressEditorRefocus: () => void; + onTabChange: (tab: PersonaPickerTab) => void; }; -export function PersonaPicker({ mx, roomId, suppressEditorRefocus }: PersonaPickerProps) { +export function PersonaPicker({ + tab = PersonaPickerTab.Global, + mx, + roomId, + suppressEditorRefocus, + onTabChange, +}: PersonaPickerProps) { const useAuthentication = useMediaAuthentication(); const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState(); const [profiles, setProfiles] = useState(undefined); - const [selectedPersona, setSelectedPersona] = useState(null); - const isPickerMenuItemSelected = (persona: PerMessageProfile) => - persona.id === selectedPersona?.id ? true : undefined; + const [selectedGlobalPersona, setSelectedGlobalPersona] = useState( + null + ); + const [selectedRoomPersona, setSelectedRoomPersona] = useState(null); + const isPickerMenuItemSelected = (persona: PerMessageProfile) => { + const selectedPersona = + tab === PersonaPickerTab.Global ? selectedGlobalPersona : selectedRoomPersona; + return persona.id === selectedPersona?.id ? true : undefined; + }; + + const defactoPersona = () => selectedRoomPersona ?? selectedGlobalPersona; const searchInputRef = useRef(null); @@ -62,8 +93,11 @@ export function PersonaPicker({ mx, roomId, suppressEditorRefocus }: PersonaPick useEffect(() => { const syncProfile = async () => { - const syncedProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - setSelectedPersona(syncedProfile ?? null); + const syncedRoomProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + setSelectedRoomPersona(syncedRoomProfile ?? null); + + const syncedGlobalProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); + setSelectedGlobalPersona(syncedGlobalProfile ?? null); }; syncProfile(); }, [mx, roomId]); @@ -132,72 +166,150 @@ export function PersonaPicker({ mx, roomId, suppressEditorRefocus }: PersonaPick }} > - - Set persona for this room - - - - {filteredProfiles?.map((profile) => ( - { - const disabling = profile.id === selectedPersona?.id; - - if (!disabling) { - setSelectedPersona(profile); - await setCurrentlyUsedPerMessageProfileIdForRoom(mx, roomId, profile.id); - } else { - setSelectedPersona(null); - await setCurrentlyUsedPerMessageProfileIdForRoom( - mx, - roomId, - undefined, - undefined, - true - ); + + + onTabChange(PersonaPickerTab.Global)} + > + + Global + + + onTabChange(PersonaPickerTab.PerRoom)} + > + + Per-room + + + + + <> + + + + {filteredProfiles?.map((profile) => ( + { + const isGlobal = tab === PersonaPickerTab.Global; + const selectedPersona = isGlobal + ? selectedGlobalPersona + : selectedRoomPersona; + const disabling = profile.id === selectedPersona?.id; + + if (!disabling) { + if (isGlobal) { + setSelectedGlobalPersona(profile); + await setCurrentlyUsedPerMessageProfileIdForAccount(mx, profile.id); + } else { + setSelectedRoomPersona(profile); + await setCurrentlyUsedPerMessageProfileIdForRoom( + mx, + roomId, + profile.id + ); + } + } else { + if (isGlobal) { + setSelectedGlobalPersona(null); + await setCurrentlyUsedPerMessageProfileIdForAccount( + mx, + undefined, + undefined, + true + ); + } else { + setSelectedRoomPersona(null); + await setCurrentlyUsedPerMessageProfileIdForRoom( + mx, + roomId, + undefined, + undefined, + true + ); + } + } + }} + before={ + + ( + + {nameInitials(profile.name)} + + )} + alt={`Avatar for profile ${profile.id}`} + /> + } - }} - before={ - - ( - - {nameInitials(profile.name)} - - )} - alt={`Avatar for profile ${profile.id}`} - /> - - } - > - - {profile.name} - - - ))} - + > + + {profile.name} + + + ))} + + + Message will use your per-room persona. + + ) : selectedGlobalPersona ? ( + <> + Message will use your global persona. + + ) : ( + <>No persona chosen. + ) + } + /> + @@ -217,7 +329,7 @@ export function PersonaPicker({ mx, roomId, suppressEditorRefocus }: PersonaPick title="Switch persona" aria-label="Switch persona" > - {selectedPersona ? ( + {(selectedRoomPersona ?? selectedGlobalPersona) ? ( ( - {nameInitials(selectedPersona.name)} + {nameInitials(defactoPersona()!.name)} )} - alt={`Avatar for profile ${selectedPersona.id}`} + alt={`Avatar for profile ${defactoPersona()!.id}`} /> ) : ( diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 82f0cfc96f..71dd4db6eb 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -198,6 +198,21 @@ type PerMessageProfileRoomAssociationWrapper = { compat?: AccountDataCompatVersion; }; +/** + * the shape of the account data for room associations, which is a wrapper around a list of associations. + * This is used to store the associations in account data, and allows us to easily add additional fields in the future if needed without breaking the existing data structure. + */ +type PerMessageProfileGlobalAssociationWrapper = { + /** + * Key-Value pairs of room ids and profile ids, used to apply a profile to all messages in a room without having to set the profile for each message individually. + * The key is the room id, and the value is the profile id. The profile id can then be used to fetch the profile data when applying the profile to a message before sending it. + * + * @type {Map} + */ + association: PerMessageProfileRoomAssociation; + compat?: AccountDataCompatVersion; +}; + /** * unwrap a profile-room-associations-wrapper * @param wrapper the wrapper to unwrap @@ -384,6 +399,33 @@ export async function setCurrentlyUsedPerMessageProfileIdForRoom( ); } +/** + * todo + */ +export async function setCurrentlyUsedPerMessageProfileIdForAccount( + mx: MatrixClient, + profileId: string | undefined, + validUntil?: number, + reset?: boolean +) { + if (reset) { + mx.deleteAccountData( + `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0] + ); + return; + } + if (!profileId) { + throw new Error("profile Id is empty, yet it isn't a reset"); + } + + const association: PerMessageProfileRoomAssociation = { profileId, validUntil }; + + mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0], + { association: association } as Parameters[1] + ); +} + /** * * @param mx the matrix client @@ -568,3 +610,18 @@ export async function getCurrentlyUsedPerMessageProfileForRoom( const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; return profileId ? pmp : undefined; } + +/** + * get the per message profile associated with the account todo + */ +export async function getCurrentlyUsedPerMessageProfileForAccount( + mx: MatrixClient +): Promise { + const accountData = mx.getAccountData( + `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0] + ); + const content: PerMessageProfileGlobalAssociationWrapper | undefined = accountData?.getContent(); + const profileId = content?.association.profileId; + const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; + return profileId ? pmp : undefined; +}