Skip to content

Commit 7cd60ed

Browse files
committed
Add Persona Picker in the Editor
1 parent 247d3e5 commit 7cd60ed

10 files changed

Lines changed: 336 additions & 4 deletions

File tree

src/app/features/room/RoomInput.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ import { PollDialog } from './poll-modals';
195195
import { LocationDialog } from './location-modal';
196196
import { useClientConfig } from '$hooks/useClientConfig';
197197
import { GifIcon } from '@phosphor-icons/react';
198+
import { PersonaPicker } from './persona-picker/PersonaPicker.tsx';
198199

199200
// Returns the event ID of the most recent non-reaction/non-edit event in a thread,
200201
// falling back to the thread root if no replies exist yet.
@@ -319,6 +320,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
319320

320321
const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat');
321322
const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying');
323+
const [pmpPickerEnable] = useSetting(settingsAtom, 'pmpPicker');
324+
322325
const emojiBtnRef = useRef<HTMLButtonElement>(null);
323326
const micBtnRef = useRef<HTMLButtonElement>(null);
324327
// Preserve stable list keys across metadata/description replacements without
@@ -1768,6 +1771,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
17681771
>
17691772
{composerIcon(PlusCircle)}
17701773
</IconButton>
1774+
{pmpPickerEnable && (
1775+
<PersonaPicker
1776+
mx={mx}
1777+
roomId={roomId}
1778+
suppressEditorRefocus={suppressEditorRefocus}
1779+
/>
1780+
)}
17711781
</>
17721782
}
17731783
after={
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { style } from '@vanilla-extract/css';
2+
import { color, config, toRem } from 'folds';
3+
4+
export const PersonaPickerMenuItem = style({
5+
backgroundColor: color.Surface.Container,
6+
minWidth: toRem(200),
7+
selectors: {
8+
'&:hover': {
9+
backgroundColor: color.Surface.ContainerHover,
10+
},
11+
'&[aria-selected]': {
12+
backgroundColor: color.Surface.ContainerActive,
13+
},
14+
},
15+
});
16+
17+
export const PersonaPickerButtonAvatar = style({
18+
border: 'solid',
19+
borderWidth: config.borderWidth.B400,
20+
borderColor: 'transparent',
21+
});
22+
23+
export const SelectedPersonaPickerButtonAvatar = style({
24+
border: 'solid',
25+
borderWidth: config.borderWidth.B400,
26+
borderColor: color.SurfaceVariant.ContainerLine,
27+
});
28+
29+
export const PersonaPickerButtonAvatarImage = style({
30+
borderRadius: 0,
31+
});
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { composerIcon, User as UserIcon } from '$components/icons/phosphor';
2+
import { UserAvatar } from '$components/user-avatar/UserAvatar.tsx';
3+
import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts';
4+
import {
5+
getCurrentlyUsedPerMessageProfileForRoom,
6+
getAllPerMessageProfiles,
7+
type PerMessageProfile,
8+
setCurrentlyUsedPerMessageProfileIdForRoom,
9+
} from '$hooks/usePerMessageProfile';
10+
import { stopPropagation } from '$utils/keyboard';
11+
import { mxcUrlToHttp } from '$utils/matrix.ts';
12+
import { mobileOrTablet } from '$utils/user-agent';
13+
import FocusTrap from 'focus-trap-react';
14+
import { nameInitials } from '$utils/common';
15+
import {
16+
Avatar,
17+
Box,
18+
config,
19+
IconButton,
20+
Input,
21+
Menu,
22+
MenuItem,
23+
PopOut,
24+
type RectCords,
25+
Scroll,
26+
Text,
27+
toRem,
28+
} from 'folds';
29+
import type { MatrixClient } from 'matrix-js-sdk';
30+
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
31+
import * as css from './PersonaPicker.css.ts';
32+
33+
type PersonaPickerProps = {
34+
mx: MatrixClient;
35+
roomId: string;
36+
suppressEditorRefocus: () => void;
37+
};
38+
39+
export function PersonaPicker({ mx, roomId, suppressEditorRefocus }: PersonaPickerProps) {
40+
const useAuthentication = useMediaAuthentication();
41+
const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState<RectCords>();
42+
const [profiles, setProfiles] = useState<PerMessageProfile[] | undefined>(undefined);
43+
const [selectedPersona, setSelectedPersona] = useState<PerMessageProfile | null>(null);
44+
const isPickerMenuItemSelected = (persona: PerMessageProfile) =>
45+
persona.id === selectedPersona?.id ? true : undefined;
46+
47+
const searchInputRef = useRef<HTMLInputElement>(null);
48+
49+
const scrollRef = useRef<HTMLDivElement>(null);
50+
const [showPersonaPicker, setShowPersonaPicker] = useState(false);
51+
52+
const [filteredProfiles, setFilteredProfiles] = useState<PerMessageProfile[] | undefined>(
53+
undefined
54+
);
55+
56+
const clearFilterInput = () => {
57+
if (searchInputRef.current) {
58+
searchInputRef.current.value = '';
59+
}
60+
setFilteredProfiles(profiles);
61+
};
62+
63+
useEffect(() => {
64+
const syncProfile = async () => {
65+
const syncedProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId);
66+
setSelectedPersona(syncedProfile ?? null);
67+
};
68+
syncProfile();
69+
}, [mx, roomId]);
70+
71+
const fetchProfiles = async (mx_: MatrixClient) => {
72+
const fetchedProfiles = await getAllPerMessageProfiles(mx_);
73+
setProfiles(fetchedProfiles);
74+
setFilteredProfiles(fetchedProfiles);
75+
console.warn(`PERSONA! Done that. ${JSON.stringify(fetchedProfiles)}`);
76+
};
77+
78+
useEffect(() => {
79+
fetchProfiles(mx);
80+
}, [mx]);
81+
82+
const filter = (e: FormEvent) => {
83+
const term = (e.target as HTMLInputElement).value;
84+
85+
const filtered = term
86+
? profiles?.filter((profile) =>
87+
searchInputRef.current
88+
? profile.name.toLocaleLowerCase().includes(searchInputRef.current?.value) ||
89+
profile.id.toLocaleLowerCase().includes(searchInputRef.current?.value)
90+
: true
91+
)
92+
: profiles;
93+
94+
setFilteredProfiles(filtered);
95+
};
96+
97+
const avatarUrl = useCallback(
98+
(profile: PerMessageProfile) => {
99+
if (profile.avatarUrl !== undefined) {
100+
return mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined;
101+
} else {
102+
return undefined;
103+
}
104+
},
105+
[mx, useAuthentication]
106+
);
107+
108+
return (
109+
<>
110+
<PopOut
111+
anchor={AddPersonaMenuAnchor}
112+
position="Top"
113+
align="Start"
114+
offset={5}
115+
content={
116+
<FocusTrap
117+
focusTrapOptions={{
118+
initialFocus: false,
119+
onActivate: () => {
120+
// HACK: getAllPerMessageProfiles returns [] on Sable first load.
121+
// BUG: On the third render the list returns empty in testing.
122+
if (profiles?.length === 0) {
123+
fetchProfiles(mx);
124+
}
125+
},
126+
onDeactivate: () => {
127+
setAddPersonaMenuAnchor(undefined);
128+
setShowPersonaPicker(false);
129+
clearFilterInput();
130+
},
131+
clickOutsideDeactivates: true,
132+
escapeDeactivates: stopPropagation,
133+
}}
134+
>
135+
<Menu>
136+
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
137+
<Text size="H6">Set persona for this room</Text>
138+
<Input
139+
ref={searchInputRef}
140+
variant="SurfaceVariant"
141+
size="400"
142+
placeholder="Search"
143+
maxLength={50}
144+
autoFocus={!mobileOrTablet()}
145+
onChange={filter}
146+
/>
147+
148+
<Scroll ref={scrollRef} size="400" style={{ maxHeight: '14rem' }}>
149+
{filteredProfiles?.map((profile) => (
150+
<MenuItem
151+
key={profile.id}
152+
size="400"
153+
radii="300"
154+
className={css.PersonaPickerMenuItem}
155+
aria-selected={isPickerMenuItemSelected(profile)}
156+
onClick={async () => {
157+
const disabling = profile.id === selectedPersona?.id;
158+
159+
if (!disabling) {
160+
setSelectedPersona(profile);
161+
await setCurrentlyUsedPerMessageProfileIdForRoom(mx, roomId, profile.id);
162+
} else {
163+
setSelectedPersona(null);
164+
await setCurrentlyUsedPerMessageProfileIdForRoom(
165+
mx,
166+
roomId,
167+
undefined,
168+
undefined,
169+
true
170+
);
171+
}
172+
}}
173+
before={
174+
<Avatar
175+
size="300"
176+
radii="400"
177+
style={{
178+
width: '2rem',
179+
height: '2rem',
180+
}}
181+
aria-label="Profile avatar"
182+
>
183+
<UserAvatar
184+
userId={profile.id}
185+
src={avatarUrl(profile)}
186+
renderFallback={() => (
187+
<Text as="span" size="H4" aria-label="Avatar fallback">
188+
{nameInitials(profile.name)}
189+
</Text>
190+
)}
191+
alt={`Avatar for profile ${profile.id}`}
192+
/>
193+
</Avatar>
194+
}
195+
>
196+
<Text truncate style={{ maxWidth: toRem(150) }}>
197+
{profile.name}
198+
</Text>
199+
</MenuItem>
200+
))}
201+
</Scroll>
202+
</Box>
203+
</Menu>
204+
</FocusTrap>
205+
}
206+
/>
207+
{
208+
<IconButton
209+
aria-pressed={showPersonaPicker}
210+
onClick={(evt) => {
211+
setShowPersonaPicker(true);
212+
setAddPersonaMenuAnchor(evt.currentTarget.getBoundingClientRect());
213+
}}
214+
onPointerDown={suppressEditorRefocus}
215+
variant="SurfaceVariant"
216+
size="300"
217+
style={{ backgroundColor: 'transparent' }}
218+
title="Switch persona"
219+
aria-label="Switch persona"
220+
>
221+
{selectedPersona ? (
222+
<Avatar
223+
size="200"
224+
radii="300"
225+
className={
226+
showPersonaPicker
227+
? css.SelectedPersonaPickerButtonAvatar
228+
: css.PersonaPickerButtonAvatar
229+
}
230+
aria-label="Profile avatar"
231+
>
232+
<UserAvatar
233+
className={css.PersonaPickerButtonAvatarImage}
234+
userId={selectedPersona.id}
235+
src={avatarUrl(selectedPersona)}
236+
renderFallback={() => (
237+
<Text as="span" size="H6" aria-label="Avatar fallback">
238+
{nameInitials(selectedPersona.name)}
239+
</Text>
240+
)}
241+
alt={`Avatar for profile ${selectedPersona.id}`}
242+
/>
243+
</Avatar>
244+
) : (
245+
composerIcon(UserIcon, { weight: showPersonaPicker ? 'fill' : 'regular' })
246+
)}
247+
</IconButton>
248+
}
249+
</>
250+
);
251+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { SequenceCard } from '$components/sequence-card';
2+
import { SettingTile } from '$components/setting-tile';
3+
import { useSetting } from '$state/hooks/settings';
4+
import { settingsAtom } from '$state/settings';
5+
import { Box, Switch, Text } from 'folds';
6+
import { SequenceCardStyle } from '../styles.css';
7+
8+
export function PickerPageSettings() {
9+
const [usePmpPicker, setUsePmpPicker] = useSetting(settingsAtom, 'pmpPicker');
10+
11+
return (
12+
<Box direction="Column" gap="100">
13+
<Text size="L400">Persona Picker</Text>
14+
<SequenceCard
15+
className={SequenceCardStyle}
16+
variant="SurfaceVariant"
17+
direction="Column"
18+
gap="100"
19+
>
20+
<SettingTile
21+
focusId="enable-pmp-picker"
22+
title="Enable Persona Picker"
23+
description="Enables a menu in the editor to pick an associated persona for that room."
24+
after={
25+
<Switch
26+
variant="Primary"
27+
value={usePmpPicker}
28+
onChange={setUsePmpPicker}
29+
title={usePmpPicker ? 'disable persona picker' : 'enable persona picker'}
30+
/>
31+
}
32+
/>
33+
</SequenceCard>
34+
</Box>
35+
);
36+
}

src/app/features/settings/Persona/ProfilesPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Box } from 'folds';
33
import { SettingsSectionPage } from '../SettingsSectionPage';
44
import { PerMessageProfileOverview } from './PerMessageProfileOverview';
55
import { PKCompatSettings } from './PKCompat';
6+
import { PickerPageSettings } from './PickerPage';
67

78
type PerMessageProfilePageProps = {
89
requestBack?: () => void;
@@ -26,6 +27,7 @@ export function PerMessageProfilePage({ requestBack, requestClose }: PerMessageP
2627
direction="Column"
2728
shrink="No"
2829
>
30+
<PickerPageSettings />
2931
<PKCompatSettings />
3032
<PerMessageProfileOverview />
3133
</Box>

src/app/generated/tauri/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Auto-generated TypeScript bindings for Tauri commands
33
* Generated by tauri-typegen v0.5.0
4-
* Generated at: 2026-07-21T17:18:34.365576100+00:00
4+
* Generated at: 2026-07-21T15:41:55.100029+00:00
55
* Generator: none
66
*
77
* Do not edit manually - regenerate using: cargo tauri-typegen generate

src/app/generated/tauri/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Auto-generated TypeScript bindings for Tauri commands
33
* Generated by tauri-typegen v0.5.0
4-
* Generated at: 2026-07-21T17:18:34.366699600+00:00
4+
* Generated at: 2026-07-21T15:41:55.100509+00:00
55
* Generator: none
66
*
77
* Do not edit manually - regenerate using: cargo tauri-typegen generate

src/app/generated/tauri/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Auto-generated TypeScript bindings for Tauri commands
33
* Generated by tauri-typegen v0.5.0
4-
* Generated at: 2026-07-21T17:18:34.367242500+00:00
4+
* Generated at: 2026-07-21T15:41:55.100609+00:00
55
* Generator: none
66
*
77
* Do not edit manually - regenerate using: cargo tauri-typegen generate

0 commit comments

Comments
 (0)