Skip to content
Draft
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
37 changes: 35 additions & 2 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
import {
getCurrentlyUsedPerMessageProfileForRoom,
PerMessageProfile,
} from '$hooks/usePerMessageProfile';
import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec';
import { SchedulePickerDialog } from './schedule-send';
import * as css from './schedule-send/SchedulePickerDialog.css';
Expand Down Expand Up @@ -270,6 +274,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
room
);

// Add state to cache the profile
const [perMessageProfile, setPerMessageProfile] = useState<PerMessageProfile | null>(null);

// Fetch and cache the profile when the roomId changes
useEffect(() => {
const fetchPmP = async () => {
const profile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId);
console.debug('Fetched per-message profile:', { profile, roomId });
setPerMessageProfile(profile ?? null); // Convert undefined to null
};

fetchPmP();
}, [mx, roomId]);

const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
Expand Down Expand Up @@ -630,6 +648,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
body,
};

if (perMessageProfile) {
console.warn(
'Using per-message profile:',
perMessageProfile.id,
perMessageProfile.name,
perMessageProfile.avatarUrl
);
content['com.beeper.per_message_profile'] = {
id: perMessageProfile.id,
displayname: perMessageProfile.name,
avatar_url: perMessageProfile.avatarUrl,
};
}

if (replyDraft && !silentReply) {
mentionData.users.add(replyDraft.userId);
}
Expand Down Expand Up @@ -724,20 +756,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
canSendReaction,
mx,
roomId,
threadRootId,
perMessageProfile,
replyDraft,
silentReply,
scheduledTime,
editingScheduledDelayId,
handleQuickReact,
commands,
sendTypingStatus,
room,
queryClient,
threadRootId,
setReplyDraft,
isEncrypted,
setEditingScheduledDelayId,
setScheduledTime,
room,
]);

const handleKeyDown: KeyboardEventHandler = useCallback(
Expand Down
314 changes: 314 additions & 0 deletions src/app/features/settings/Profiles/PerMessageProfileEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import { SequenceCard } from '$components/sequence-card';
import { Box, Button, Text, Avatar, config, Icon, IconButton, Icons, Input } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { mxcUrlToHttp } from '$utils/matrix';
import { useFilePicker } from '$hooks/useFilePicker';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useObjectURL } from '$hooks/useObjectURL';
import { createUploadAtom } from '$state/upload';
import { UserAvatar } from '$components/user-avatar';
import { CompactUploadCardRenderer } from '$components/upload-card';
import { addOrUpdatePerMessageProfile, deletePerMessageProfile } from '$hooks/usePerMessageProfile';
import { SequenceCardStyle } from '../styles.css';

/**
* the props we use for the per-message profile editor, which is used to edit a per-message profile. This is used in the settings page when the user wants to edit a profile.
*/
type PerMessageProfileEditorProps = {
mx: MatrixClient;
profileId: string;
avatarMxcUrl?: string;
displayName?: string;
onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void;
onDelete?: (profileId: string) => void;
};

export function PerMessageProfileEditor({
mx,
profileId,
avatarMxcUrl,
displayName,
onChange,
onDelete,
}: Readonly<PerMessageProfileEditorProps>) {
const useAuthentication = useMediaAuthentication();
const [currentDisplayName, setCurrentDisplayName] = useState(displayName ?? '');
const [newDisplayName, setNewDisplayName] = useState(currentDisplayName);
const [imageFile, setImageFile] = useState<File | undefined>();
const [avatarMxc, setAvatarMxc] = useState(avatarMxcUrl);
const imageFileURL = useObjectURL(imageFile);
const avatarUrl = useMemo(() => {
if (imageFileURL) return imageFileURL;
if (avatarMxc) {
return mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined;
}
return undefined;
}, [imageFileURL, avatarMxc, mx, useAuthentication]);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: { status: string; mxc: string }) => {
if (upload && upload.status === 'success') {
setAvatarMxc(upload.mxc);
if (onChange) onChange({ id: profileId, name: newDisplayName, avatarUrl: upload.mxc });
}
setImageFile(undefined);
},
[onChange, profileId, newDisplayName]
);
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewDisplayName(e.target.value);
}, []);

const [changingDisplayName, setChangingDisplayName] = useState(false);
// This state is used to disable the display name input while the user is changing it, to prevent them from making changes while the save operation is in progress.
// It is set to true when the user clicks the save button, and set back to false when the save operation is complete.
const [disableSetDisplayname, setDisableSetDisplayname] = useState(false);

const hasChanges = useMemo(
() => newDisplayName !== (currentDisplayName ?? '') || !!imageFile,
[newDisplayName, currentDisplayName, imageFile]
);

// Reset handler for display name
const handleReset = useCallback(() => {
setNewDisplayName(currentDisplayName);
setChangingDisplayName(false);
setDisableSetDisplayname(false);
}, [currentDisplayName]);

const handleSave = useCallback(() => {
addOrUpdatePerMessageProfile(mx, {
id: profileId,
name: newDisplayName,
avatarUrl: avatarMxc,
}).then(() => {
setCurrentDisplayName(newDisplayName);
});
setChangingDisplayName(false);
setDisableSetDisplayname(false);
}, [mx, profileId, newDisplayName, avatarMxc]);

const handleDelete = useCallback(() => {
deletePerMessageProfile(mx, profileId).then(() => {
setCurrentDisplayName('');
if (onDelete) onDelete(profileId);
});
}, [mx, profileId]);

return (
<Box
direction="Row"
gap="200"
grow="Yes"
style={{
width: '100%',
minWidth: 500,
paddingTop: config.space.S400,
paddingBottom: config.space.S400,
alignItems: 'center',
justifyContent: 'center',
}}
role="form"
aria-labelledby={`profile-editor-title-${profileId}`}
>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Row"
gap="500"
style={{
width: '100%',
minWidth: 500,
minHeight: 100,
maxHeight: 200,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'visible',
}}
>
<Text
size="H6"
style={{ position: 'absolute', top: 8, left: 16 }}
id={`profile-editor-title-${profileId}`}
>
Profile ID: {profileId}
</Text>
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{ minWidth: 80, maxWidth: 100, maxHeight: 100, flexShrink: 0, overflow: 'visible', marginTop: 20 }}
aria-label="Avatar and upload"
>
<Avatar
size="300"
radii="300"
style={{
width: 'clamp(25px, 8vw, 50px)',
height: 'clamp(25px, 8vw, 50px)',
minWidth: 48,
minHeight: 48,
maxWidth: 72,
maxHeight: 72,
}}
aria-label="Profile avatar"
>
<UserAvatar
userId={profileId}
src={avatarUrl}
renderFallback={() => (
<Text size="H4" aria-label="Avatar fallback">
p
</Text>
)}
alt={`Avatar for profile ${profileId}`}
/>
</Avatar>
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
style={{
width: 'clamp(30px, 6vw, 60px)',
marginTop: config.space.S100,
overflow: 'visible',
fontSize: 14,
padding: '0 8px',
}}
aria-label="Upload avatar image"
>
<Text size="T200">Upload</Text>
</Button>
{uploadAtom && (
<Box
gap="100"
direction="Column"
style={{ width: '100%', maxWidth: 100, maxHeight: 100, overflow: 'visible' }}
aria-label="Upload area"
>
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
)}
</Box>
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
style={{ flex: 1, minWidth: 0, height: '100%' }}
aria-label="Display name input"
>
<label
htmlFor={`displayNameInput-${profileId}`}
style={{ marginBottom: config.space.S200, alignSelf: 'flex-start' }}
>
<Text size="T300">Display Name:</Text>
</label>
<Input
required
name="displayNameInput"
id={`displayNameInput-${profileId}`}
value={newDisplayName}
onChange={handleNameChange}
variant="Secondary"
radii="300"
style={{
flex: 1,
minWidth: 0,
width: '100%',
maxWidth: 'clamp(200px, 60vw, 480px)',
paddingRight: config.space.S200,
fontSize: 16,
height: 50,
}}
placeholder="Display name"
readOnly={changingDisplayName || disableSetDisplayname}
aria-label={`Display name for ${profileId}`}
title={`Display name for ${profileId}`}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
aria-label="Reset display name"
title="Reset display name"
>
<Icon src={Icons.Cross} size="100" aria-label="Reset icon" />
</IconButton>
)
}
/>
</Box>
{/* Rechte Spalte: Save Button */}
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
style={{ minWidth: 120, maxWidth: 140, flexShrink: 0, height: '100%' }}
aria-label={`Save button area for ${profileId}`}
>
<Button
onClick={handleSave}
size="300"
radii="300"
variant="Primary"
disabled={!hasChanges}
style={{
minWidth: 120,
height: 'clamp(30px, 6vw, 50px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label={`Save profile changes for ${profileId}`}
title={`Save profile changes for ${profileId}`}
>
<Text size="B300">Save</Text>
</Button>
<Button
onClick={handleDelete}
size="300"
radii="300"
variant="Critical"
fill="None"
style={{
minWidth: 120,
height: 'clamp(30px, 6vw, 50px)',
marginTop: config.space.S100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label={`Delete profile ${profileId}`}
title={`Delete profile ${profileId}`}
>
<Text size="B300">Delete</Text>
</Button>
</Box>
</SequenceCard>
</Box>
);
}
Loading
Loading