diff --git a/.changeset/allow_changingediting_pmp_on_messages.md b/.changeset/allow_changingediting_pmp_on_messages.md new file mode 100644 index 0000000000..b8c17b4f16 --- /dev/null +++ b/.changeset/allow_changingediting_pmp_on_messages.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Allow changing/editing PMP on messages diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 0f5b27dee8..823e10139c 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -1,4 +1,9 @@ -import type { RoomPinnedEventsEventContent, StateEvents } from '$types/matrix-sdk'; +import type { + MatrixClient, + RoomMessageEventContent, + RoomPinnedEventsEventContent, + StateEvents, +} from '$types/matrix-sdk'; import { type Room, type MatrixEvent, type Relations, EventType } from '$types/matrix-sdk'; import { canEditEvent, @@ -48,7 +53,7 @@ import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; import { EmojiBoard } from '$components/emoji-board'; import { MemoizedBody, type ReactionHandler } from '$features/room/message'; import { useRecentEmoji } from '$hooks/useRecentEmoji'; -import { BookmarkIcon } from '@phosphor-icons/react'; +import { BookmarkIcon, UserIcon } from '@phosphor-icons/react'; import { computeBookmarkId, createBookmarkItem, @@ -61,6 +66,11 @@ import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '$unstable/prefixes'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; import type { IImageInfo } from '$types/matrix/common'; import { getIncomingMediaMxcUrl } from '../MsgTypeRenderers'; +import { TemporaryPersonaPicker } from '$features/room/persona-picker/PersonaPicker'; +import { type PerMessageProfile } from '$hooks/usePerMessageProfile'; +import { buildReplacementPmpContent } from '$features/room/buildReplacementContent'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; function WrappedMessage({ isModal, @@ -390,6 +400,40 @@ function OptionsEmojiBoard({ ); } +type OptionsReproxyPersonaPickerProps = { + mx: MatrixClient; + mEvent: MatrixEvent; + roomId: string; + closeMenu: () => void; + anchor: RectCords; +}; +function OptionsReproxyPersonaPicker({ + mx, + mEvent, + roomId, + closeMenu, + anchor, +}: OptionsReproxyPersonaPickerProps) { + const reproxyMessage = async (profile: PerMessageProfile | undefined) => { + const content = buildReplacementPmpContent(mEvent.getContent(), mEvent.getId()!, profile); + await mx.sendMessage(roomId, content as RoomMessageEventContent); + + closeMenu(); + }; + + return ( + <> + + + ); +} + export function OptionQuickMenu({ mEvent, room, @@ -399,6 +443,7 @@ export function OptionQuickMenu({ relations, onReplyClick, onEditId, + onReproxyId, hideReadReceipts, showDeveloperTools, canPinEvent, @@ -505,6 +550,7 @@ export function OptionQuickMenu({ relations={relations} onReplyClick={onReplyClick} onEditId={onEditId} + onReproxyId={onReproxyId} hideReadReceipts={hideReadReceipts} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} @@ -546,6 +592,7 @@ export type OptionMenuProps = { startThread?: boolean ) => void; onEditId?: (eventId?: string) => void; + onReproxyId?: (profileId?: string) => void; hideReadReceipts?: boolean; showDeveloperTools?: boolean; canPinEvent?: boolean; @@ -593,6 +640,7 @@ function OptionMenu({ evtTimeline && getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); const isEdited = !!edits?.length; + const [showPersonaSetting] = useSetting(settingsAtom, 'showPersonaSetting'); const onTotalClose = () => { setModal(null); @@ -606,6 +654,20 @@ function OptionMenu({ const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); + const [reproxyPickerAnchor, setReproxyPickerAnchor] = useState(); + + const handleOpenReproxyPicker: MouseEventHandler = (evt) => { + const target = isModal + ? { x: 0, y: innerHeight, width: 0, height: 0 } + : (evt.currentTarget.parentElement?.parentElement?.getBoundingClientRect() ?? { + x: 0, + y: 0, + width: 0, + height: 0, + }); + setReproxyPickerAnchor(target); + }; + const handleOpenEmojiBoard: MouseEventHandler = (evt) => { // THIS MAGIC NUMBER SHOULD BE FIXED WHEN SOMEONE FIGURES OUT WHY THE LACK OF IT CREATES A GAP IN THE EMOJIBOARD const target = isModal @@ -634,6 +696,15 @@ function OptionMenu({ ActualMessage={} /> )} + {reproxyPickerAnchor !== undefined && ( + + )} )} + {canEditEvent(mx, mEvent) && showPersonaSetting && ( + + + Change Persona + + + )} {!hideReadReceipts && ( import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -2194,7 +2194,7 @@ export const RoomInput = forwardRef( )} {pmpPickerEnable && ( - ]*>.*?<\/strong>/, + '' + ); + + oldContent.formatted_body = newFormattedBody; + oldContent.body = newPlainBody; + } + + if (newProfile) { + const escapedName = sanitizeText(newProfile.name); + const htmlPrefix = `${escapedName}: `; + + if (oldContent.formatted_body) { + oldContent.formatted_body = htmlPrefix + oldContent.formatted_body; + } else { + // we don't have a formatted body, but we need one + oldContent.format = 'org.matrix.custom.html'; + const escapedBody = sanitizeText(oldContent.body).replaceAll('\n', '
'); + oldContent.formatted_body = `${htmlPrefix}${escapedBody}`; + } + + const pmpPrefix = `${newProfile.name}: `; + oldContent.body = pmpPrefix + oldContent.body; + } + + const newContent = { + ...oldContent, + [MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME]: profileBeeperFormat, + }; + const content: IContent = { + ...newContent, + 'm.new_content': newContent, + 'm.relates_to': { event_id: eventId, rel_type: RelationType.Replace }, + }; + + return content; +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 6926a8d6be..c3e13c9608 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -111,6 +111,7 @@ export type MessageProps = { startThread?: boolean ) => void; onEditId?: (eventId?: string) => void; + onReproxyId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; reactions?: ReactNode; @@ -340,6 +341,7 @@ function MessageInternal( onReplyClick, onReactionToggle, onEditId, + onReproxyId, reply, reactions, hideReadReceipts, @@ -980,6 +982,7 @@ function MessageInternal( relations={relations} onReplyClick={onReplyClick} onEditId={onEditId} + onReproxyId={onReproxyId} hideReadReceipts={hideReadReceipts} showDeveloperTools={showDeveloperTools} canPinEvent={canPinEvent} diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx index 5f098bdf33..a5173d2946 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -36,13 +36,17 @@ 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'; +import { InfoIcon, XIcon } from '@phosphor-icons/react'; import { ThemeKind, useActiveTheme } from '$hooks/useTheme.ts'; const pillStyles = { cursor: 'pointer', } as const; +export enum PersonaPickerPresentation { + TemporarySelectorMenu = 'TemporarySelectorMenu', + PersistentPicker = 'PersistentPicker', +} export enum PersonaPickerTab { Global = 'Global', PerRoom = 'PerRoom', @@ -51,23 +55,82 @@ export enum PersonaPickerTab { type PersonaPickerProps = { tab?: PersonaPickerTab; mx: MatrixClient; - roomId: string; - suppressEditorRefocus: () => void; - onTabChange: (tab: PersonaPickerTab) => void; - latchedPersona: PerMessageProfile | undefined; + roomId?: string; + suppressEditorRefocus?: () => void; + onTabChange?: (tab: PersonaPickerTab) => void; + latchedPersona?: PerMessageProfile; + hideTabs?: boolean; + onPersonaSelect?: (persona: PerMessageProfile | undefined) => void; + requestClose?: () => void; + showNoneOption?: boolean; + hideButton?: boolean; + anchor?: RectCords; }; -export function PersonaPicker({ - tab = PersonaPickerTab.Global, +function PersonaSelectMenuTabs({ + tab, + setTab, +}: { + tab: PersonaPickerTab; + setTab: (tab: PersonaPickerTab) => void; +}) { + return ( + + setTab(PersonaPickerTab.Global)} + > + + Global + + + setTab(PersonaPickerTab.PerRoom)} + > + + Per-room + + + + ); +} + +export function TemporaryPersonaPicker(props: PersonaPickerProps) { + return ( + + ); +} +export function PersistentPersonaPicker(props: PersonaPickerProps) { + return ; +} + +function PersonaPicker({ + tab: tabProp = PersonaPickerTab.Global, mx, roomId, suppressEditorRefocus, - onTabChange, latchedPersona, -}: PersonaPickerProps) { + onPersonaSelect, + anchor, + requestClose, + presentation, + onTabChange, +}: PersonaPickerProps & { presentation: PersonaPickerPresentation }) { const useAuthentication = useMediaAuthentication(); - const activeTheme = useActiveTheme(); - const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState(); + + const persistent = presentation === PersonaPickerPresentation.PersistentPicker; + + const [tab, setTab] = useState(tabProp); + const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState(anchor); const [profiles, setProfiles] = useState(undefined); const [selectedGlobalPersona, setSelectedGlobalPersona] = useState( null @@ -75,41 +138,20 @@ export function PersonaPicker({ const [selectedRoomPersona, setSelectedRoomPersona] = useState( latchedPersona ?? null ); - const isPickerMenuItemSelected = (persona: PerMessageProfile) => { - const selectedPersona = - tab === PersonaPickerTab.Global ? selectedGlobalPersona : selectedRoomPersona; - return persona.id === selectedPersona?.id ? true : undefined; - }; - - const nameColor = useCallback( - (persona: PerMessageProfile) => - activeTheme.kind === ThemeKind.Dark ? persona.colors?.on_dark : persona.colors?.on_light, - [activeTheme] - ); const defactoPersona = () => selectedRoomPersona ?? selectedGlobalPersona; - const searchInputRef = useRef(null); - - const scrollRef = useRef(null); - const [filteredProfiles, setFilteredProfiles] = useState( - undefined - ); - - const clearFilterInput = () => { - if (searchInputRef.current) { - searchInputRef.current.value = ''; - } - setFilteredProfiles(profiles); - }; + const activeTheme = useActiveTheme(); useEffect(() => { const syncProfile = async () => { - const syncedRoomProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - if (!selectedRoomPersona) setSelectedRoomPersona(syncedRoomProfile ?? null); + if (roomId) { + const syncedRoomProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + if (!selectedRoomPersona) setSelectedRoomPersona(syncedRoomProfile ?? null); - const syncedGlobalProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); - setSelectedGlobalPersona(syncedGlobalProfile ?? null); + const syncedGlobalProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); + setSelectedGlobalPersona(syncedGlobalProfile ?? null); + } }; syncProfile(); }, [mx, roomId, profiles, latchedPersona, selectedRoomPersona]); @@ -120,9 +162,8 @@ export function PersonaPicker({ setFilteredProfiles(fetchedProfiles); }; - useEffect(() => { - fetchProfiles(mx); - }, [mx]); + const scrollRef = useRef(null); + const searchInputRef = useRef(null); const filter = useCallback( (e: FormEvent) => { @@ -141,6 +182,23 @@ export function PersonaPicker({ }, [profiles] ); + const nameColor = useCallback( + (persona: PerMessageProfile) => + activeTheme.kind === ThemeKind.Dark ? persona.colors?.on_dark : persona.colors?.on_light, + [activeTheme] + ); + + const isSelected = (persona: PerMessageProfile | undefined) => { + if (!persona) return undefined; + const selected = tab === PersonaPickerTab.Global ? selectedGlobalPersona : selectedRoomPersona; + return persona.id === selected?.id ? true : undefined; + }; + + const [filteredProfiles, setFilteredProfiles] = useState(profiles); + + useEffect(() => { + fetchProfiles(mx); + }, [mx]); const avatarUrl = useCallback( (profile: PerMessageProfile) => { @@ -153,16 +211,58 @@ export function PersonaPicker({ [mx, useAuthentication] ); + const handleSelect = useCallback( + async (profile: PerMessageProfile | undefined) => { + if (onPersonaSelect) { + onPersonaSelect(profile); + return; + } + if (!roomId) return; + + const isGlobal = tab === PersonaPickerTab.Global; + const selectedPersona = isGlobal ? selectedGlobalPersona : selectedRoomPersona; + const disabling = !profile || 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); + } + } + }, + [ + mx, + roomId, + tab, + selectedRoomPersona, + selectedGlobalPersona, + setSelectedGlobalPersona, + setSelectedRoomPersona, + onPersonaSelect, + ] + ); + return ( { setAddPersonaMenuAnchor(undefined); - clearFilterInput(); + requestClose?.(); }} menu={ @@ -171,33 +271,15 @@ export function PersonaPicker({ gap="100" style={{ padding: config.space.S200, minWidth: '18rem' }} > - - onTabChange(PersonaPickerTab.Global)} - > - - Global - - - onTabChange(PersonaPickerTab.PerRoom)} - > - - Per-room - - - - + {persistent && ( + { + onTabChange?.(newTab); + setTab(newTab); + }} + /> + )} <> - + {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 - ); - } - } - }} + aria-selected={isSelected(profile)} + onClick={() => handleSelect(profile)} before={ ))} + {presentation !== PersonaPickerPresentation.PersistentPicker && ( + handleSelect(undefined)} + before={ +
+ {menuIcon(XIcon, { weight: 'regular', size: 28 })}{' '} +
+ } + > + + No persona + +
+ )}
- - Message will use your per-room persona. - - ) : selectedGlobalPersona ? ( - <> - Message will use your global persona. - - ) : ( - <>No persona chosen. - ) - } - /> + {presentation === PersonaPickerPresentation.PersistentPicker && ( + + Message will use your per-room persona. + + ) : selectedGlobalPersona ? ( + <> + Message will use your global persona. + + ) : ( + <>No persona chosen. + ) + } + /> + )}
} > - { - // getAllPerMessageProfiles can return an empty list during initial startup. - if (profiles?.length === 0) { - fetchProfiles(mx); - } - setAddPersonaMenuAnchor(evt.currentTarget.getBoundingClientRect()); - }} - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - style={{ backgroundColor: 'transparent' }} - title="Switch persona" - aria-label="Switch persona" - > - {(selectedRoomPersona ?? selectedGlobalPersona) ? ( - { + // getAllPerMessageProfiles can return an empty list during initial startup. + if (profiles?.length === 0) { + fetchProfiles(mx); } - aria-label="Profile avatar" - > - ( - - {nameInitials(defactoPersona()!.name)} - - )} - alt={`Avatar for profile ${defactoPersona()!.id}`} - /> - - ) : ( - composerIcon(UserIcon, { weight: AddPersonaMenuAnchor ? 'fill' : 'regular' }) - )} - + setAddPersonaMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + style={{ backgroundColor: 'transparent' }} + title="Switch persona" + aria-label="Switch persona" + > + {(selectedRoomPersona ?? selectedGlobalPersona) ? ( + + ( + + {nameInitials(defactoPersona()!.name)} + + )} + alt={`Avatar for profile ${defactoPersona()!.id}`} + /> + + ) : ( + composerIcon(UserIcon, { weight: AddPersonaMenuAnchor ? 'fill' : 'regular' }) + )} + + )}
); }