diff --git a/assets/icons/image.svg b/assets/icons/image.svg new file mode 100644 index 00000000..a8045483 --- /dev/null +++ b/assets/icons/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/user-add.svg b/assets/icons/user-add.svg new file mode 100644 index 00000000..fcc3d414 --- /dev/null +++ b/assets/icons/user-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/UI/ChangeRoomAvatar.js b/components/UI/ChangeRoomAvatar.js new file mode 100644 index 00000000..55143108 --- /dev/null +++ b/components/UI/ChangeRoomAvatar.js @@ -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 ( + <> + setIsChangingAvatarDialogueOpen(true)} + > + + + { isChangingAvatarDialogueOpen && ( + setIsChangingAvatarDialogueOpen(false)} + contentLabel={t('Change Room Avatar')} + shouldCloseOnOverlayClick={true} + headline={t('Change Room Avatar')} + > + + { errorMessage && { errorMessage } } + + + ) } + + ); +}; + +export default ChangeRoomAvatar; diff --git a/components/UI/ConfirmCancelButtons.js b/components/UI/ConfirmCancelButtons.js index 49a91deb..5d36cd37 100644 --- a/components/UI/ConfirmCancelButtons.js +++ b/components/UI/ConfirmCancelButtons.js @@ -18,8 +18,10 @@ 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); @@ -27,13 +29,14 @@ const CancelButton = styled.button` } `; -const ConfirmCancelButtons = ({ children, disabled, onClick, onCancel }) => { +const ConfirmCancelButtons = ({ children, disabled, onClick, onCancel, cancelTitle }) => { const { t } = useTranslation(); + console.log(children); return ( { children ? children : t('Confirm') } - { t('Cancel') } + { cancelTitle || t('Cancel') } ); }; diff --git a/components/UI/Datalist.js b/components/UI/Datalist.js new file mode 100644 index 00000000..2cd2fdf7 --- /dev/null +++ b/components/UI/Datalist.js @@ -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 ( +
+ + { options.length > 0 && isOpen && ( + + { options.map((option, index) => ( + handleSelect(option)}> + { keysToDisplay.map(key => { + return + { option[key] } + ; + }) } + + + )) } + + ) } +
+ ); +} + +export default Datalist; diff --git a/components/UI/ImageUpload.js b/components/UI/ImageUpload.js new file mode 100644 index 00000000..7952388b --- /dev/null +++ b/components/UI/ImageUpload.js @@ -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 ( +
+ { currentAvatarUrl && + + { isUploadingImage && ( + + + + ) } + + } + + + { !currentAvatarUrl ? : + { + imageUploadRef.current.click(); + }} + onCancel={handleDelete} + cancelTitle={t('Delete')}> + { isUploadingImage ? + : + t('Change') } + + } +
+ ); +}; + +export default ImageUpload; diff --git a/components/UI/InviteUsersToMatrixRoom/UserListEntry.js b/components/UI/InviteUsersToMatrixRoom/UserListEntry.js new file mode 100644 index 00000000..b8938439 --- /dev/null +++ b/components/UI/InviteUsersToMatrixRoom/UserListEntry.js @@ -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 + { user.display_name } ({ user.user_id }) + + { isInviting ? || '✓' : } + + + + ; +}; +export default UserListEntry; diff --git a/components/UI/InviteUsersToMatrixRoom/index.js b/components/UI/InviteUsersToMatrixRoom/index.js new file mode 100644 index 00000000..c899d75d --- /dev/null +++ b/components/UI/InviteUsersToMatrixRoom/index.js @@ -0,0 +1,118 @@ +/** + * This component renders a button whoch onClick opens a Modal. + * `activeContexts` is the array of room IDs for the currently set context spaces. + * + * @param {string} roomId (valid matrix roomId) + * @param {string} roomName (name of the matrix room) + * + * @return {React.ReactElement} + * + * @TODO + * - create separate component for the invitation dialogue so it can be used without the button and maybe without the modal view. + * - maybe swap datalist for a different UI element. datalist handling is far from optimal, since we have to manually get the userId and displayName after a user has selected the user to invite. + * Even though we already have it from the `matrixClient.searchUserDirectory` call. The problem is that afaik there is no way to parse the object from the