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_persona_picker_in_editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

# Add Persona picker in Editor
10 changes: 10 additions & 0 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -319,6 +320,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(

const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat');
const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying');
const [pmpPickerEnable] = useSetting(settingsAtom, 'pmpPicker');

const emojiBtnRef = useRef<HTMLButtonElement>(null);
const micBtnRef = useRef<HTMLButtonElement>(null);
// Preserve stable list keys across metadata/description replacements without
Expand Down Expand Up @@ -1768,6 +1771,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
{composerIcon(PlusCircle)}
</IconButton>
{pmpPickerEnable && (
<PersonaPicker
mx={mx}
roomId={roomId}
suppressEditorRefocus={suppressEditorRefocus}
/>
)}
</>
}
after={
Expand Down
31 changes: 31 additions & 0 deletions src/app/features/room/persona-picker/PersonaPicker.css.ts
Original file line number Diff line number Diff line change
@@ -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,
});
250 changes: 250 additions & 0 deletions src/app/features/room/persona-picker/PersonaPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<RectCords>();
const [profiles, setProfiles] = useState<PerMessageProfile[] | undefined>(undefined);
const [selectedPersona, setSelectedPersona] = useState<PerMessageProfile | null>(null);
const isPickerMenuItemSelected = (persona: PerMessageProfile) =>
persona.id === selectedPersona?.id ? true : undefined;

const searchInputRef = useRef<HTMLInputElement>(null);

const scrollRef = useRef<HTMLDivElement>(null);
const [showPersonaPicker, setShowPersonaPicker] = useState(false);

const [filteredProfiles, setFilteredProfiles] = useState<PerMessageProfile[] | undefined>(
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 (
<>
<PopOut
anchor={AddPersonaMenuAnchor}
position="Top"
align="Start"
offset={5}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onActivate: () => {
// 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,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
<Text size="H6">Set persona for this room</Text>
<Input
ref={searchInputRef}
variant="SurfaceVariant"
size="400"
placeholder="Search"
maxLength={50}
autoFocus={!mobileOrTablet()}
onChange={filter}
/>

<Scroll ref={scrollRef} size="400" style={{ maxHeight: '14rem' }}>
{filteredProfiles?.map((profile) => (
<MenuItem
key={profile.id}
size="400"
radii="300"
className={css.PersonaPickerMenuItem}
aria-selected={isPickerMenuItemSelected(profile)}
onClick={async () => {
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={
<Avatar
size="300"
radii="400"
style={{
width: '2rem',
height: '2rem',
}}
aria-label="Profile avatar"
>
<UserAvatar
userId={profile.id}
src={avatarUrl(profile)}
renderFallback={() => (
<Text as="span" size="H4" aria-label="Avatar fallback">
{nameInitials(profile.name)}
</Text>
)}
alt={`Avatar for profile ${profile.id}`}
/>
</Avatar>
}
>
<Text truncate style={{ maxWidth: toRem(150) }}>
{profile.name}
</Text>
</MenuItem>
))}
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
/>
{
<IconButton
aria-pressed={showPersonaPicker}
onClick={(evt) => {
setShowPersonaPicker(true);
setAddPersonaMenuAnchor(evt.currentTarget.getBoundingClientRect());
}}
onPointerDown={suppressEditorRefocus}
variant="SurfaceVariant"
size="300"
style={{ backgroundColor: 'transparent' }}
title="Switch persona"
aria-label="Switch persona"
>
{selectedPersona ? (
<Avatar
size="200"
radii="300"
className={
showPersonaPicker
? css.SelectedPersonaPickerButtonAvatar
: css.PersonaPickerButtonAvatar
}
aria-label="Profile avatar"
>
<UserAvatar
className={css.PersonaPickerButtonAvatarImage}
userId={selectedPersona.id}
src={avatarUrl(selectedPersona)}
renderFallback={() => (
<Text as="span" size="H6" aria-label="Avatar fallback">
{nameInitials(selectedPersona.name)}
</Text>
)}
alt={`Avatar for profile ${selectedPersona.id}`}
/>
</Avatar>
) : (
composerIcon(UserIcon, { weight: showPersonaPicker ? 'fill' : 'regular' })
)}
</IconButton>
}
</>
);
}
36 changes: 36 additions & 0 deletions src/app/features/settings/Persona/PickerPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box direction="Column" gap="100">
<Text size="L400">Persona Picker</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="100"
>
<SettingTile
focusId="enable-pmp-picker"
title="Enable Persona Picker"
description="Enables a menu in the editor to pick an associated persona for that room."
after={
<Switch
variant="Primary"
value={usePmpPicker}
onChange={setUsePmpPicker}
title={usePmpPicker ? 'disable persona picker' : 'enable persona picker'}
/>
}
/>
</SequenceCard>
</Box>
);
}
2 changes: 2 additions & 0 deletions src/app/features/settings/Persona/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,7 @@ export function PerMessageProfilePage({ requestBack, requestClose }: PerMessageP
direction="Column"
shrink="No"
>
<PickerPageSettings />
<PKCompatSettings />
<PerMessageProfileOverview />
</Box>
Expand Down
2 changes: 1 addition & 1 deletion src/app/generated/tauri/commands.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/generated/tauri/events.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/generated/tauri/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading