Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change avatar of matrix rooms/spaces #61

Open
wants to merge 61 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6b41482
add icon and rudimentary component for inviting users
Aug 23, 2023
d040393
add logic to search for matrix users
Aug 23, 2023
43ba9b9
add invite user button
Aug 23, 2023
5851589
use react-modal package
Aug 23, 2023
5018104
add invitation logic and user feedback, close modal after sucessfull …
Aug 24, 2023
81f677a
add comments and TODO
Aug 24, 2023
cf3ec1e
improve object name
Aug 24, 2023
7369a8d
fix document undefined error
Aug 24, 2023
baf3db1
check for userId and displayName manually
Aug 24, 2023
73fceee
close modal after successful invitation
Aug 29, 2023
3f8bde8
merge main into invite-users
Aug 29, 2023
5b73822
merge main into invite-users
Aug 31, 2023
e1f40f2
remove unnecessary function and call function directly from matrixClient
Aug 31, 2023
77f8c79
use logger instead of console.log
Sep 5, 2023
1c935a0
replace `datalist` with `ServiceTable` and update imports
Sep 5, 2023
84cf241
remove unused imports
Sep 5, 2023
1036306
css clean up
Sep 5, 2023
3772fe4
clear search results on successful invitation
Sep 5, 2023
b7aa8ea
add translations and fix typos
Sep 5, 2023
882ad9a
remove duplicate
Sep 5, 2023
a44410a
add default modal component
Sep 5, 2023
5cb4e99
change modal style
Sep 6, 2023
67f7ac7
remove unused import
Sep 6, 2023
ef16180
change variable to from `name` to `roomName` and user user-add icon i…
Sep 6, 2023
626f47e
introduce and implement custom datalist component using ServiceTable
Sep 8, 2023
69bb68f
Create Datalist component and use when searching for users to invite
Sep 12, 2023
c52e8c9
Merge pull request #57 from medienhaus/invite-users-custom-datalist
andirueckel Sep 12, 2023
1df63a7
add translation
Sep 12, 2023
b0812bf
add translation
Sep 12, 2023
d6ca6a2
fix: disable stylelint for Modal custom styles to not trigger eslint
andirueckel Sep 12, 2023
8ee975f
Merge branch 'invite-users' into change-room-avatar
Sep 12, 2023
579a548
feat: add general image upload component
Sep 13, 2023
76915d4
feat: add component to change room avatars and implement into /etherpad
Sep 13, 2023
f43fe20
refactor: use `ImageUpload` component
Sep 13, 2023
2029b27
chore: add translations
Sep 13, 2023
12fa478
merge main into invite-users
Sep 13, 2023
f13dae0
Merge branch 'main' into invite-users
Sep 13, 2023
d9c00b4
refactor: close datalist when string empty
Sep 20, 2023
d8dc5bc
refactor: improve and shorten functions
Sep 20, 2023
5360bd6
merge main into invite-users
Sep 21, 2023
f42995c
chore: add comment and improve JSDoc
Sep 21, 2023
5b6d2c6
Merge branch 'main' into invite-users
Sep 21, 2023
9fca902
refactor: add InviteUsers
Sep 21, 2023
5226795
refactor: add InviteUsers to spacedeck
Sep 21, 2023
4b74202
Merge remote-tracking branch 'origin/invite-users' into invite-users
Sep 21, 2023
89357c5
Merge branch 'main' into invite-users
Sep 21, 2023
a17dfd6
Merge branch 'main' into invite-users
Sep 26, 2023
8e49701
fix: invitations not working and wrap user id in brackets
Sep 26, 2023
35dd409
refactor: remove unused import
Sep 26, 2023
1e2f1ec
merge main into invite-users
Oct 10, 2023
e4ce0c3
update package-lock.json
Oct 10, 2023
95c217a
refactor: use TextButton for svg
Oct 10, 2023
7a87e2a
chore: sort alphabetically after merge
Oct 10, 2023
79a597b
update package-lock
Oct 10, 2023
610c79d
merge invite-users into change-room-avatar
Oct 11, 2023
3773b47
refactor: remove unnecessary preview function, overlay old image with…
Oct 11, 2023
edc41c7
refactor: only show avatar container if there is a current avatar. di…
Oct 11, 2023
558f33b
refactor: move close icon and header inside DefaultModal
Oct 11, 2023
03aad8d
Merge branch 'invite-users' into change-room-avatar
Oct 11, 2023
2cb23df
refactor: add optional warning style and optional cancel title to cha…
Oct 11, 2023
66bafaa
feat: Enhance ImageUpload component with delete functionality
Oct 11, 2023
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
1 change: 1 addition & 0 deletions assets/icons/image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/user-add.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions components/UI/ChangeRoomAvatar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useAuth } from '../../lib/Auth';
import ImageIcon from '../../assets/icons/image.svg';
import DefaultModal from './Modal';
import ImageUpload from './ImageUpload';
import ErrorMessage from './ErrorMessage';
import TextButton from './TextButton';

/**
* ChangeRoomAvatar component for changing the avatar of a Matrix room.
*
* @component
* @param {string} roomId - The ID of the room for which the avatar is being changed.
* @returns {JSX.Element} - The rendered component.
*/

const ChangeRoomAvatar = ({ roomId }) => {
const matrixClient = useAuth().getAuthenticationProvider('matrix').getMatrixClient();
const room = matrixClient.getRoom(roomId);
const currentAvatarUrl = room.getAvatarUrl(matrixClient.baseUrl);
const canChangeAvatar = room.currentState.maySendStateEvent('m.room.avatar', matrixClient.getUserId());
const { t } = useTranslation();
const [isChangingAvatarDialogueOpen, setIsChangingAvatarDialogueOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState('');

const uploadRoomAvatar = async (imageUrl) => {
const request = {
method: 'PUT',
headers: { Authorization: 'Bearer ' + localStorage.getItem('medienhaus_access_token') },
body: JSON.stringify({
url: imageUrl,
}),
};
await fetch(localStorage.getItem('medienhaus_hs_url') + `_matrix/client/r0/rooms/${roomId}/state/m.room.avatar/`, request)
.catch((error) => {
setErrorMessage(error.data?.error || t('Something went wrong, please try again.'));
},
);
setErrorMessage('');
};

if (!canChangeAvatar) return `${t('You don’t have the required permissions')} ...`;

return (
<>
<TextButton
title={t('Change Room Avatar')}
onClick={() => setIsChangingAvatarDialogueOpen(true)}
>
<ImageIcon fill="var(--color-foreground)" />
</TextButton>
{ isChangingAvatarDialogueOpen && (
<DefaultModal
isOpen={isChangingAvatarDialogueOpen}
onRequestClose={() => setIsChangingAvatarDialogueOpen(false)}
contentLabel={t('Change Room Avatar')}
shouldCloseOnOverlayClick={true}
headline={t('Change Room Avatar')}
>
<ImageUpload
roomId={roomId}
currentAvatarUrl={currentAvatarUrl}
callback={uploadRoomAvatar} />
{ errorMessage && <ErrorMessage>{ errorMessage }</ErrorMessage> }

</DefaultModal>
) }
</>
);
};

export default ChangeRoomAvatar;
11 changes: 7 additions & 4 deletions components/UI/ConfirmCancelButtons.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,25 @@ const ConfirmButton = styled.button`
`;

const CancelButton = styled.button`
color: var(--color-foreground);
background-color: var(--color-background);
color: ${props => props.warning ? 'var(--color-background)' : 'var(--color-foreground)'};
${props => props.warning && 'border-color: var(--color-notification);'}

background-color: ${props => props.warning ? 'var(--color-notification)' : 'var(--color-background)'};

&:disabled {
color: var(--color-disabled);
background-color: var(--color-background);
}
`;

const ConfirmCancelButtons = ({ children, disabled, onClick, onCancel }) => {
const ConfirmCancelButtons = ({ children, disabled, onClick, onCancel, cancelTitle }) => {
const { t } = useTranslation();
console.log(children);

return (
<ConfirmCancelButtonsWrapper>
<ConfirmButton type="submit" disabled={disabled} onClick={onClick}>{ children ? children : t('Confirm') }</ConfirmButton>
<CancelButton type="reset" disabled={disabled} onClick={onCancel}>{ t('Cancel') }</CancelButton>
<CancelButton warning type="reset" disabled={disabled} onClick={onCancel}>{ cancelTitle || t('Cancel') }</CancelButton>
</ConfirmCancelButtonsWrapper>
);
};
Expand Down
114 changes: 114 additions & 0 deletions components/UI/Datalist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useState, useRef } from 'react';

import { ServiceTable } from './ServiceTable';

/**
* An input component that functions as a datalist and can be controlled with arrow keys and mouse clicks.
*
* @component
* @param {string[]} options - An array of Objects for the datalist.
* @param {function} onChange - function to execute when input changes, receives string as first parameter.
* @param {function} onSelect - function to execute when a result from the datalist was selected
* @param {Array} keysToDisplay - Array of strings of key values to be displayed as results
* @returns {React.JSX.Element} The Datalist component.
*/

function Datalist({ options, onChange, onSelect, keysToDisplay }) {
const [value, setValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef(null);

const handleChange = async (e) => {
setIsLoading(true);
setValue(e.target.value);
onSelect(null);
await onChange(e.target.value);
if (e.target.value !== '') setIsOpen(true);
else {
// if the input is empty we close the datalist
setIsOpen(false);
}
setIsLoading(false);
};

const handleKeyDown = (e) => {
// Handle keyboard navigation
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
const newIndex = Math.min(selectedIndex + 1, options.length - 1);
setSelectedIndex(newIndex);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (isOpen) {
const newIndex = Math.max(selectedIndex - 1, -1);
setSelectedIndex(newIndex);
}
} else if (e.key === 'Enter' && isOpen && selectedIndex !== -1) {
e.preventDefault();
const selectedOption = options[selectedIndex];
handleSelect(selectedOption);
}
};

const handleBlur = () => {
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(-1);
}, 100); // Delay closing the datalist to allow clicking on options
};

const handleSelect = (selectedOption) => {
setValue(stringifySelection(selectedOption));
onSelect(selectedOption);
setSelectedIndex(-1);
setIsOpen(false);
inputRef.current.focus();
};

const stringifySelection = (selectedOption) => {
return keysToDisplay
.map((key) => selectedOption[key])
.filter((value) => value !== undefined)
.join(' (') + ')'; // wrap in brackets
};

return (
<div>
<input
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
ref={inputRef}
disabled={isLoading}
/>
{ options.length > 0 && isOpen && (
<ServiceTable>
{ options.map((option, index) => (
<ServiceTable.Row
key={index}
selected={selectedIndex === index}
onClick={() => handleSelect(option)}>
{ keysToDisplay.map(key => {
return <ServiceTable.Cell
key={key}>
{ option[key] }
</ServiceTable.Cell>;
}) }

</ServiceTable.Row>
)) }
</ServiceTable>
) }
</div>
);
}

export default Datalist;
122 changes: 122 additions & 0 deletions components/UI/ImageUpload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';

import { useAuth } from '../../lib/Auth';
import LoadingSpinner from './LoadingSpinner';
import LoadingSpinnerInline from './LoadingSpinnerInline';
import ConfirmCancelButtons from './ConfirmCancelButtons';

const Avatar = styled.img`
display: block;
max-width: 100%;
max-height: 60vh;
margin: 0 auto var(--margin) auto;
`;

const AvatarContainer = styled.div`
position: relative;
`;

const SpinnerOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgb(255 255 255 / 70%);
`;

/**
* ImageUpload component for selecting and uploading an image.
*
* @param {String} currentAvatarUrl - string with the url for the current avatar
* @param {function} callback - The callback function to handle the uploaded image.
* @returns {JSX.Element} - The rendered component.
*/

const ImageUpload = ({ currentAvatarUrl, roomId, callback }) => {
const [isUploadingImage, setIsUploadingImage] = useState(false);
const matrixClient = useAuth().getAuthenticationProvider('matrix').getMatrixClient();
const { t } = useTranslation();
const imageUploadRef = useRef(null);

/**
* Handles the file upload and sends the selected image to a Matrix room.
*
* @param {Event} event - The file input change event.
*/

const handleUpload = useCallback(async (event) => {
const file = event.target.files[0];

if (!file) return;

setIsUploadingImage(true);
const formData = new FormData();
formData.append('image', file);
// Upload the image to the Matrix content repository
const uploadedImage = await matrixClient.uploadContent(file, { name: file.name })
.catch((error) => {
alert(error.data?.error || t('Something went wrong, please try again.'));
},
);
// Callback to handle the uploaded image's content URI
if (uploadedImage) callback(uploadedImage.content_uri);

setIsUploadingImage(false);
}, [callback, matrixClient, t]);

const handleDelete = async () => {
await matrixClient.sendStateEvent(roomId, 'm.room.avatar', {})
.catch((error) => {
alert(error.data?.error || t('Something went wrong, please try again.'));
},
);
};

return (
<div>
{ currentAvatarUrl && <AvatarContainer>
<Avatar src={currentAvatarUrl} width="100%" height="100%" />
{ isUploadingImage && (
<SpinnerOverlay>
<LoadingSpinner />
</SpinnerOverlay>
) }
</AvatarContainer>
}
<input type="file" accept="image/*" ref={imageUploadRef} style={{ display: 'none' }} onChange={handleUpload} />

{ !currentAvatarUrl ? <button
disabled={isUploadingImage}
type="button"
onClick={() => {
imageUploadRef.current.click();
}}
>
{ !currentAvatarUrl && isUploadingImage ?
<LoadingSpinnerInline inverted /> :
t('Upload') }
</button> :
<ConfirmCancelButtons warning
disabled={isUploadingImage}
onClick={() => {
imageUploadRef.current.click();
}}
onCancel={handleDelete}
cancelTitle={t('Delete')}>
{ isUploadingImage ?
<LoadingSpinnerInline inverted /> :
t('Change') }
</ConfirmCancelButtons>
}
</div>
);
};

export default ImageUpload;
33 changes: 33 additions & 0 deletions components/UI/InviteUsersToMatrixRoom/UserListEntry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { ServiceTable } from '../ServiceTable';
import LoadingSpinnerInline from '../LoadingSpinnerInline';
import TextButton from '../TextButton';
import UserAddIcon from '../../../assets/icons/user-add.svg';

const UserListEntry = ({ user, handleInvite, roomName }) => {
const [isInviting, setIsInviting] = useState(false);
const { t } = useTranslation();

const handleClick = async (e) => {
e.preventDefault();
setIsInviting(true);
await handleInvite(user.user_id, user.display_name);
setIsInviting(false);
};

return <ServiceTable.Row>
<ServiceTable.Cell>{ user.display_name } ({ user.user_id })</ServiceTable.Cell>
<ServiceTable.Cell>
<TextButton
title={t('invite {{user}} to join {{room}}', { user: user.display_name, room: roomName })}
onClick={handleClick}
disabled={isInviting}
>{ isInviting ? <LoadingSpinnerInline /> || '✓' : <UserAddIcon fill="var(--color-foreground)" /> }
</TextButton>
</ServiceTable.Cell>

</ServiceTable.Row>;
};
export default UserListEntry;
Loading