diff --git a/.changeset/add_persona_picker_in_editor.md b/.changeset/add_persona_picker_in_editor.md new file mode 100644 index 0000000000..881b11bec2 --- /dev/null +++ b/.changeset/add_persona_picker_in_editor.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Add Persona picker in Editor diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 5452c8e92d..29e8136e15 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -195,6 +195,7 @@ import { PollDialog } from './poll-modals'; import { LocationDialog } from './location-modal'; import { useClientConfig } from '$hooks/useClientConfig'; import { GifIcon } from '@phosphor-icons/react'; +import { PersonaPicker } from './persona-picker/PersonaPicker.tsx'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. @@ -319,6 +320,8 @@ export const RoomInput = forwardRef( const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat'); const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying'); + const [pmpPickerEnable] = useSetting(settingsAtom, 'pmpPicker'); + const emojiBtnRef = useRef(null); const micBtnRef = useRef(null); // Preserve stable list keys across metadata/description replacements without @@ -1768,6 +1771,13 @@ export const RoomInput = forwardRef( > {composerIcon(PlusCircle)} + {pmpPickerEnable && ( + + )} } after={ diff --git a/src/app/features/room/persona-picker/PersonaPicker.css.ts b/src/app/features/room/persona-picker/PersonaPicker.css.ts new file mode 100644 index 0000000000..133ac18d12 --- /dev/null +++ b/src/app/features/room/persona-picker/PersonaPicker.css.ts @@ -0,0 +1,31 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const PersonaPickerMenuItem = style({ + backgroundColor: color.Surface.Container, + minWidth: toRem(200), + selectors: { + '&:hover': { + backgroundColor: color.Surface.ContainerHover, + }, + '&[aria-selected]': { + backgroundColor: color.Surface.ContainerActive, + }, + }, +}); + +export const PersonaPickerButtonAvatar = style({ + border: 'solid', + borderWidth: config.borderWidth.B400, + borderColor: 'transparent', +}); + +export const SelectedPersonaPickerButtonAvatar = style({ + border: 'solid', + borderWidth: config.borderWidth.B400, + borderColor: color.SurfaceVariant.ContainerLine, +}); + +export const PersonaPickerButtonAvatarImage = style({ + borderRadius: 0, +}); diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx new file mode 100644 index 0000000000..c88a48f0cb --- /dev/null +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -0,0 +1,250 @@ +import { composerIcon, User as UserIcon } from '$components/icons/phosphor'; +import { UserAvatar } from '$components/user-avatar/UserAvatar.tsx'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; +import { + getCurrentlyUsedPerMessageProfileForRoom, + getAllPerMessageProfiles, + type PerMessageProfile, + setCurrentlyUsedPerMessageProfileIdForRoom, +} from '$hooks/usePerMessageProfile'; +import { stopPropagation } from '$utils/keyboard'; +import { mxcUrlToHttp } from '$utils/matrix.ts'; +import { mobileOrTablet } from '$utils/user-agent'; +import FocusTrap from 'focus-trap-react'; +import { nameInitials } from '$utils/common'; +import { + Avatar, + Box, + config, + IconButton, + Input, + Menu, + MenuItem, + PopOut, + type RectCords, + Scroll, + Text, + toRem, +} 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'; + +type PersonaPickerProps = { + mx: MatrixClient; + roomId: string; + suppressEditorRefocus: () => void; +}; + +export function PersonaPicker({ mx, roomId, suppressEditorRefocus }: 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 searchInputRef = useRef(null); + + const scrollRef = useRef(null); + const [showPersonaPicker, setShowPersonaPicker] = useState(false); + + const [filteredProfiles, setFilteredProfiles] = useState( + undefined + ); + + const clearFilterInput = () => { + if (searchInputRef.current) { + searchInputRef.current.value = ''; + } + setFilteredProfiles(profiles); + }; + + useEffect(() => { + const syncProfile = async () => { + const syncedProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + setSelectedPersona(syncedProfile ?? null); + }; + syncProfile(); + }, [mx, roomId]); + + const fetchProfiles = async (mx_: MatrixClient) => { + const fetchedProfiles = await getAllPerMessageProfiles(mx_); + setProfiles(fetchedProfiles); + setFilteredProfiles(fetchedProfiles); + }; + + useEffect(() => { + fetchProfiles(mx); + }, [mx]); + + const filter = (e: FormEvent) => { + const term = (e.target as HTMLInputElement).value.toLocaleLowerCase(); + + const filtered = term + ? profiles?.filter((profile) => + searchInputRef.current + ? profile.name.toLocaleLowerCase().includes(searchInputRef.current?.value) || + profile.id.toLocaleLowerCase().includes(searchInputRef.current?.value) + : true + ) + : profiles; + + setFilteredProfiles(filtered); + }; + + const avatarUrl = useCallback( + (profile: PerMessageProfile) => { + if (profile.avatarUrl !== undefined) { + return mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined; + } else { + return undefined; + } + }, + [mx, useAuthentication] + ); + + return ( + <> + { + // HACK: getAllPerMessageProfiles returns [] on Sable first load. + // BUG: On the third render the list returns empty in testing. + if (profiles?.length === 0) { + fetchProfiles(mx); + } + }, + onDeactivate: () => { + setAddPersonaMenuAnchor(undefined); + setShowPersonaPicker(false); + clearFilterInput(); + }, + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + 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 + ); + } + }} + before={ + + ( + + {nameInitials(profile.name)} + + )} + alt={`Avatar for profile ${profile.id}`} + /> + + } + > + + {profile.name} + + + ))} + + + + + } + /> + { + { + setShowPersonaPicker(true); + setAddPersonaMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + style={{ backgroundColor: 'transparent' }} + title="Switch persona" + aria-label="Switch persona" + > + {selectedPersona ? ( + + ( + + {nameInitials(selectedPersona.name)} + + )} + alt={`Avatar for profile ${selectedPersona.id}`} + /> + + ) : ( + composerIcon(UserIcon, { weight: showPersonaPicker ? 'fill' : 'regular' }) + )} + + } + + ); +} diff --git a/src/app/features/settings/Persona/PickerPage.tsx b/src/app/features/settings/Persona/PickerPage.tsx new file mode 100644 index 0000000000..6b1ffe5187 --- /dev/null +++ b/src/app/features/settings/Persona/PickerPage.tsx @@ -0,0 +1,36 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function PickerPageSettings() { + const [usePmpPicker, setUsePmpPicker] = useSetting(settingsAtom, 'pmpPicker'); + + return ( + + Persona Picker + + + } + /> + + + ); +} diff --git a/src/app/features/settings/Persona/ProfilesPage.tsx b/src/app/features/settings/Persona/ProfilesPage.tsx index b89abd8598..961d8af0b0 100644 --- a/src/app/features/settings/Persona/ProfilesPage.tsx +++ b/src/app/features/settings/Persona/ProfilesPage.tsx @@ -3,6 +3,7 @@ import { Box } from 'folds'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { PerMessageProfileOverview } from './PerMessageProfileOverview'; import { PKCompatSettings } from './PKCompat'; +import { PickerPageSettings } from './PickerPage'; type PerMessageProfilePageProps = { requestBack?: () => void; @@ -26,6 +27,7 @@ export function PerMessageProfilePage({ requestBack, requestClose }: PerMessageP direction="Column" shrink="No" > + diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index bab4047e6f..c8a0243cb9 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-21T17:18:34.365576100+00:00 + * Generated at: 2026-07-21T15:41:55.100029+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts index f4dcb4717d..da6d230e82 100644 --- a/src/app/generated/tauri/events.ts +++ b/src/app/generated/tauri/events.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-21T17:18:34.366699600+00:00 + * Generated at: 2026-07-21T15:41:55.100509+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 3b55fc4525..618b78697a 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-21T17:18:34.367242500+00:00 + * Generated at: 2026-07-21T15:41:55.100609+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 57e0db04a1..587f341197 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-07-21T17:18:34.362712900+00:00 + * Generated at: 2026-07-21T15:41:55.098206+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d36c3dc27c..44a4aa964b 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -224,6 +224,7 @@ export interface Settings { highlightMentions: boolean; pkCompat: boolean; pmpProxying: boolean; + pmpPicker: boolean; mentionInReplies: boolean; profileChangePropagation: ProfileChangePropagation; showPersonaSetting: boolean; @@ -395,6 +396,7 @@ export const defaultSettings: Settings = { highlightMentions: true, pkCompat: false, pmpProxying: false, + pmpPicker: false, mentionInReplies: true, profileChangePropagation: 'unchanged', showPersonaSetting: false,