From b03e1f7695e1264608d59e52638104b093bdaebd Mon Sep 17 00:00:00 2001 From: SlyRock Date: Thu, 21 Nov 2024 10:56:05 +0100 Subject: [PATCH 1/7] wip broadcast options Mentions working properly --- frontend/package.json | 7 +- .../src/common/blocks/ActivityBlock/Note.jsx | 3 + frontend/src/common/blocks/PostBlock.jsx | 238 ++++++++++++++---- .../src/hooks/useMentions/MentionsList.jsx | 94 +++++++ .../src/hooks/useMentions/renderMentions.js | 59 +++++ frontend/src/hooks/useMentions/useMentions.js | 16 ++ frontend/yarn.lock | 44 ++++ 7 files changed, 415 insertions(+), 46 deletions(-) create mode 100644 frontend/src/hooks/useMentions/MentionsList.jsx create mode 100644 frontend/src/hooks/useMentions/renderMentions.js create mode 100644 frontend/src/hooks/useMentions/useMentions.js diff --git a/frontend/package.json b/frontend/package.json index 93d1dc6bb..c9beddcee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,13 +18,18 @@ "unlink-packages": "yalc remove --all && rm -rf node_modules/@semapps && rm -rf node_modules/@activitypods && yarn install --force" }, "dependencies": { - "@mui/styles": "^5.14.13", "@activitypods/react": "2.0.4", + "@mui/styles": "^5.14.13", "@semapps/activitypub-components": "1.0.3", "@semapps/auth-provider": "1.0.3", "@semapps/semantic-data-provider": "1.0.3", + "@tiptap/extension-hard-break": "^2.10.2", + "@tiptap/extension-mention": "^2.10.2", + "@tiptap/extension-placeholder": "^2.10.2", + "@tiptap/suggestion": "^2.10.2", "dayjs": "^1.11.10", "isobject": "^4.0.0", + "ra-input-rich-text": "^5.3.4", "ra-language-french": "^4.16.0", "react": "^18.2.0", "react-admin": "^4.16.0", diff --git a/frontend/src/common/blocks/ActivityBlock/Note.jsx b/frontend/src/common/blocks/ActivityBlock/Note.jsx index e82068d32..8b1cf60f9 100644 --- a/frontend/src/common/blocks/ActivityBlock/Note.jsx +++ b/frontend/src/common/blocks/ActivityBlock/Note.jsx @@ -28,6 +28,9 @@ const Note = ({ object, activity, clickOnContent }) => { content = Object.values(content)?.[0]; } + //Handle carriage return + content = content.replaceAll('\n', '
') + // Find all mentions const mentions = arrayOf(object.tag || activity?.tag).filter(tag => tag.type === 'Mention'); diff --git a/frontend/src/common/blocks/PostBlock.jsx b/frontend/src/common/blocks/PostBlock.jsx index 5550bac5e..b25ace118 100644 --- a/frontend/src/common/blocks/PostBlock.jsx +++ b/frontend/src/common/blocks/PostBlock.jsx @@ -1,24 +1,30 @@ -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect, useState, useMemo } from 'react'; import { Form, - TextInput, useNotify, useTranslate, useGetIdentity, useRedirect, useDataProvider -} from "react-admin"; -import { useLocation } from "react-router-dom"; -import { Card, Box, Button, IconButton, CircularProgress, Backdrop } from "@mui/material"; -import SendIcon from "@mui/icons-material/Send"; -import InsertPhotoIcon from "@mui/icons-material/InsertPhoto"; -import DeleteIcon from "@mui/icons-material/Delete"; +} from 'react-admin'; +import { useLocation } from 'react-router-dom'; +import { Card, Box, Button, IconButton, CircularProgress, Backdrop } from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import InsertPhotoIcon from '@mui/icons-material/InsertPhoto'; +import DeleteIcon from '@mui/icons-material/Delete'; import { useOutbox, OBJECT_TYPES, - PUBLIC_URI, -} from "@semapps/activitypub-components"; -import { useCallback } from "react"; + PUBLIC_URI +} from '@semapps/activitypub-components'; +import { useCallback } from 'react'; +import { RichTextInput, DefaultEditorOptions } from 'ra-input-rich-text'; +import { useCollection } from '@semapps/activitypub-components'; +import useMentions from '../../hooks/useMentions/useMentions.js'; +import Placeholder from '@tiptap/extension-placeholder'; +import Mention from '@tiptap/extension-mention'; +import { HardBreak } from '@tiptap/extension-hard-break'; +import { uniqBy, sortBy } from 'lodash'; const PostBlock = ({ inReplyTo, mention }) => { const dataProvider = useDataProvider(); @@ -32,9 +38,20 @@ const PostBlock = ({ inReplyTo, mention }) => { const [imageFiles, setImageFiles] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + //List of mentionable actors + const { items: followers } = useCollection('followers', { dereferenceItems: true }); + const { items: following } = useCollection('following', { dereferenceItems: true }); + const mentionables = useMemo(() => sortBy(uniqBy([...followers, ...following], 'id'), 'preferredUsername').map((actor) => ({ + id: actor.id, + label: actor['preferredUsername'], + actor: actor + })), [followers, following]); + + const suggestions = useMentions(mentionables); + // Doesn't work useEffect(() => { - if (hash === "#reply" && inputRef.current) { + if (hash === '#reply' && inputRef.current) { inputRef.current.focus(); } }, [hash, inputRef.current]); @@ -45,62 +62,135 @@ const PostBlock = ({ inReplyTo, mention }) => { return imageUrl; } catch (error) { console.error(error); - throw new Error(translate("app.notification.image_upload_error")); + throw new Error(translate('app.notification.image_upload_error')); } }, [dataProvider, translate]); - const handleAttachments = useCallback(async () => { + const handleAttachments = useCallback(async () => { const attachments = await Promise.all( imageFiles.map(async (file) => { const imageUrl = await uploadImage(file.file); return { - type: "Image", + type: 'Image', mediaType: file.type, - url: imageUrl, + url: imageUrl }; }) ); return attachments; }, [imageFiles, uploadImage]); - const clearForm = useCallback(() => { + const clearForm = useCallback(() => { // still looking for a way to clear the actual form // Clearing local URL for image preview (avoid memory leaks) imageFiles.forEach((image) => URL.revokeObjectURL(image.preview)); setImageFiles([]); }, []); + /* + The RichTextInput provides an HTML result with (by default) a new

for each new line + The HardBreak extension avoids multiple paragraphs by using
when enter is pressed + We end up with a wrapping

and some
inside so let's make it look like what TextInput would have produced + and collect mentioned users URI at the same time + e.g. +

line1
line2
@alice

+ => { + processedContent: "line1\nline2\n@alice", + mentionedUserUris : ["http://mypod.store/alice"] + } + */ + const processEditorContent = useCallback((content) => { + const document = new DOMParser().parseFromString(content, 'text/html'); + const mentionNodes = Array.from(document.body.getElementsByClassName('mention')); + + const mentionedUsersUris = []; + + mentionNodes.forEach((node) => { + const userUri = node.attributes['data-id'].value; + const userLabel = node.attributes['data-label'].value; + const link = document.createElement('a'); + link.setAttribute('href', userUri); + link.setAttribute('class', 'mention'); + const atTextNode = document.createTextNode('@'); + const spanNode = document.createElement('span'); + spanNode.textContent = userLabel; + link.appendChild(atTextNode); + link.appendChild(spanNode); + + node.parentNode.replaceChild(link, node); + mentionedUsersUris.push(userUri); + }); + + const paragraph = document.querySelector('p'); + return { + processedContent: paragraph + ? paragraph.innerHTML.replace(//gi, '\n').trim() + : '', + mentionedUsersUris + }; + }, []); + + const getFormattedMentions = useCallback((mentionedUsersUris) => { + return mentionedUsersUris.map((uri) => { + const actor = mentionables.find((m) => m.id === uri); + if (actor) { + const actorName = actor.actor['foaf:nick'] || actor.actor.preferredUsername || 'unknown'; + const instance = new URL(actor.id).host; + return { + type: 'Mention', + href: actor.id, + name: `@${actorName}@${instance}` + }; + } + return null; + }).filter(Boolean); + }, []); + const onSubmit = useCallback( async (values, { reset }) => { setIsSubmitting(true); try { + const { processedContent, mentionedUsersUris } = processEditorContent(values.content); + + const recipients = [PUBLIC_URI, identity?.webIdData?.followers, ...mentionedUsersUris]; + if (mention) { + recipients.push(mention); + } + const activity = { type: OBJECT_TYPES.NOTE, attributedTo: outbox.owner, - content: values.content, + content: processedContent, inReplyTo, - to: mention - ? [PUBLIC_URI, identity?.webIdData?.followers, mention.uri] - : [PUBLIC_URI, identity?.webIdData?.followers], + to: recipients }; + //handle attachments let attachments = await handleAttachments(); if (attachments.length > 0) { activity.attachment = attachments; } + //handle mentions + const formattedMentions = getFormattedMentions(mentionedUsersUris); + if (formattedMentions.length > 0) { + activity.tag = formattedMentions; + } + + //post the activity const activityUri = await outbox.post(activity); - notify("app.notification.message_sent", { type: "success" }); + notify('app.notification.message_sent', { type: 'success' }); clearForm(); if (inReplyTo) { redirect(`/activity/${encodeURIComponent(activityUri)}`); } } catch (e) { - notify("app.notification.activity_send_error", { - type: "error", - messageArgs: { error: e.message }, + notify('app.notification.activity_send_error', { + type: 'error', + messageArgs: { error: e.message } }); + console.error(e); } finally { setIsSubmitting(false); } @@ -112,7 +202,7 @@ const PostBlock = ({ inReplyTo, mention }) => { const files = Array.from(event.target.files); const newFiles = files.map((file) => ({ file, - preview: URL.createObjectURL(file), + preview: URL.createObjectURL(file) })); setImageFiles((prevFiles) => [...prevFiles, ...newFiles]); }, []); @@ -146,9 +236,9 @@ const PostBlock = ({ inReplyTo, mention }) => { left: 0, right: 0, bottom: 0, - margin: 1, + margin: 0, zIndex: (theme) => theme.zIndex.drawer + 1, - backgroundColor: 'rgba(0, 0, 0, 0.3)', + backgroundColor: 'rgba(0, 0, 0, 0.1)', borderRadius: 1 }} open={isSubmitting} @@ -156,20 +246,78 @@ const PostBlock = ({ inReplyTo, mention }) => {
- } + editorOptions={{ + ...DefaultEditorOptions, + extensions: [ + ...DefaultEditorOptions.extensions.map(ext => + ext.name === 'starterKit' + ? ext.configure({ + hardBreak: false + }) + : ext + ), + //To avoid creating a new paragraph (

) each time Enter is pressed + HardBreak.extend({ + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.setHardBreak() + }; + } + }), + Placeholder.configure({ + placeholder: inReplyTo + ? translate('app.input.reply') + : translate('app.input.message') + }), + Mention.configure({ + HTMLAttributes: { + class: 'mention' + }, + suggestion: suggestions + }) + ] + }} + sx={{ + //To display the placeholder, as per tiptap documentation + '.ProseMirror p.is-editor-empty:first-of-type::before': { + content: `attr(data-placeholder)`, + float: 'left', + color: 'rgba(0, 0, 0, 0.6)', + pointerEvents: 'none', + height: 0 + }, + //Styling the RichTextInput to look like a TextInput + '& .RaRichTextInput-editorContent': { + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: '4px', + padding: '8.5px 14px 8.5px 14px' + }, + '& .tiptap.ProseMirror': { + backgroundColor: '#fff', + minHeight: '91px', + outline: 'none', + border: 'none', + fontSize: '16px', + color: '#000', + padding: 0, + }, + '& .tiptap.ProseMirror:hover': { + backgroundColor: '#fff' + }, + '& .tiptap.ProseMirror:focus': { + backgroundColor: '#fff', + border: 'none' + } + }} fullWidth multiline - minRows={4} - sx={{ m: 0, mb: imageFiles.length > 0 ? -2 : -4 }} - autoFocus={hash === "#reply"} + autoFocus={hash === '#reply'} /> {/*Preview of selected pictures*/} @@ -179,7 +327,7 @@ const PostBlock = ({ inReplyTo, mention }) => { display: 'flex', gap: 1, flexWrap: 'wrap', - mt: 1, + mt: 1 }} > {imageFiles.map((image, index) => ( @@ -189,7 +337,7 @@ const PostBlock = ({ inReplyTo, mention }) => { height: 80, borderRadius: 1, overflow: 'hidden', - position: 'relative', + position: 'relative' }} > { style={{ width: '100%', height: '100%', - objectFit: 'cover', + objectFit: 'cover' }} /> { padding: '4px', '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.9)', - color: '#ff5252', + color: '#ff5252' } }} size="small" @@ -225,7 +373,7 @@ const PostBlock = ({ inReplyTo, mention }) => { )} - 0 ? 2 : 2}> + 0 ? 2 : 0}> diff --git a/frontend/src/hooks/useMentions/MentionsList.jsx b/frontend/src/hooks/useMentions/MentionsList.jsx new file mode 100644 index 000000000..22455b865 --- /dev/null +++ b/frontend/src/hooks/useMentions/MentionsList.jsx @@ -0,0 +1,94 @@ +import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import makeStyles from '@mui/styles/makeStyles'; + +const useStyles = makeStyles(theme => ({ + items: { + background: '#fff', + borderRadius: '0.5rem', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1)', + color: 'rgba(0, 0, 0, 0.8)', + fontSize: '0.9rem', + overflow: 'hidden', + padding: '0.2rem', + position: 'relative' + }, + item: { + background: 'transparent', + border: '1px solid transparent', + borderRadius: '0.4rem', + display: 'block', + margin: 0, + padding: '0.2rem 0.4rem', + textAlign: 'left', + width: '100%', + '&.selected': { + borderColor: '#000' + } + } +})); + +export default forwardRef((props, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const classes = useStyles(); + + const selectItem = index => { + const item = props.items[index]; + + if (item) { + props.command(item); + } + }; + + const upHandler = () => { + setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => setSelectedIndex(0), [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; + } + })); + + return ( +

+ {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
Aucun résultat
+ )} +
+ ); +}); diff --git a/frontend/src/hooks/useMentions/renderMentions.js b/frontend/src/hooks/useMentions/renderMentions.js new file mode 100644 index 000000000..669491786 --- /dev/null +++ b/frontend/src/hooks/useMentions/renderMentions.js @@ -0,0 +1,59 @@ +import { ReactRenderer } from '@tiptap/react'; +import tippy from 'tippy.js'; +import MentionsList from './MentionsList'; + +const renderMentions = () => { + let component; + let popup; + + return { + onStart: props => { + component = new ReactRenderer(MentionsList, { + props, + editor: props.editor + }); + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start' + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return + } + popup[0].setProps({ + getReferenceClientRect: props.clientRect + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + } + }; +}; + +export default renderMentions; diff --git a/frontend/src/hooks/useMentions/useMentions.js b/frontend/src/hooks/useMentions/useMentions.js new file mode 100644 index 000000000..cd23b8c9f --- /dev/null +++ b/frontend/src/hooks/useMentions/useMentions.js @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import renderMentions from './renderMentions'; + +const useMentions = data => { + const items = useMemo( + () => ({ query }) => data.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase())).slice(0, 5) + , [data] + ); + + return { + items, + render: renderMentions + }; +}; + +export default useMentions; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7d905e571..4c772b9c6 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -991,6 +991,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.2.4.tgz#607b2682376c5ced086258f8329eb080e8047627" integrity sha512-Y6htT/RDSqkQ1UwG2Ia+rNVRvxrKPOs3RbqKHPaWr3vbFWwhHyKhMCvi/FqfI3d5pViVHOZQ7jhb5hT/a0BmNw== +"@tiptap/extension-hard-break@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.10.2.tgz#d99e72d77871a7a3a4db3b6c2ad8af2fc4ca3e19" + integrity sha512-jEVKEe8I+Ai/qYjVf6Idg2Gpp1Cxn4O4twJ0MnlEdzoaEHgt/OTU5NO0PBZMpoe/4BkOvkETZmqRbrcGsapeYQ== + "@tiptap/extension-hard-break@^2.2.4": version "2.2.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.2.4.tgz#2be463b2f23fc8f57004f3481829aa0b236c17f6" @@ -1043,6 +1048,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.2.4.tgz#f787223facf952691136806839e4e65cbf721aaa" integrity sha512-myUlwpbrQgWfRJwG4UHM2PbiSp+squJv6LPKfKINs5yDxIproaZ0/4TAJt3heeSXZJnboPAQxSP7eLd5pY8lBw== +"@tiptap/extension-mention@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.10.2.tgz#cfd86933b79bf9d9b50aea21d7c44a5370373daa" + integrity sha512-LluRJeQTHd+d/M7qZ5H2sDDDoKwKWS6TqOdoJbtGBue8pFmKytnO+4885iLtepbF3xZCNxEG1vb7aMUFZnVdFQ== + "@tiptap/extension-ordered-list@^2.2.4": version "2.2.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.2.4.tgz#dfa6c6869a3d16fe5b2a10749ffe5b999346efd2" @@ -1058,6 +1068,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.4.tgz#d75572f6fb0cb3bbbedfa2ced49c55285ae8fdd5" integrity sha512-UL4Fn9T33SoS7vdI3NnSxBJVeGUIgCIutgXZZ5J8CkcRoDIeS78z492z+6J+qGctHwTd0xUL5NzNJI82HfiTdg== +"@tiptap/extension-placeholder@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.10.2.tgz#113818b030a560c4403dc25ba23a1ac87de5fb38" + integrity sha512-QWzih69SetWYiylQzHsK29dMR8z1ParfcEw05hy3yyWqXE1DiKEot6rOGV1xenMVEA/SNGYYhQia15Bvco95TA== + "@tiptap/extension-strike@^2.2.4": version "2.2.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.2.4.tgz#f987a6fe7b85e3179b413792ae3f33dd9c086e01" @@ -1145,6 +1160,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.2.4.tgz#746e4659fb4be2f49ad43ee5aa9801cc8154889c" integrity sha512-g6HHsKM6K3asW+ZlwMYyLCRqCRaswoliZOQofY4iZt5ru5HNTSzm3YW4XSyW5RGXJIuc319yyrOFgtJ3Fyu5rQ== +"@tiptap/suggestion@^2.10.2": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.10.2.tgz#307c44298d5e37edc051d5becfda56da6e656ed4" + integrity sha512-HaLhKEH0bJ6ojXdsxSsZFlM9BYJ3Xaph7CbHA4Eq7ivR9Y0YylQvt6er40Eg5VEzMJQPNYlSkYxPLkzSCqDXqw== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1648,6 +1668,11 @@ clsx@^2.1.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb" integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -3567,6 +3592,25 @@ ra-input-rich-text@^4.11.0: "@tiptap/starter-kit" "^2.0.3" clsx "^1.1.1" +ra-input-rich-text@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/ra-input-rich-text/-/ra-input-rich-text-5.3.4.tgz#8596973e75a4bce1d09f7595f184eb3328ab9de3" + integrity sha512-Ke6Y9iibjAYTIUWKhd7Ajx+YZzjD4n9vF0fKV6qMUPI5o8GOffTRhY2YTF5UZT2uSo6gaFY1AdDjgV+tRKjtmA== + dependencies: + "@tiptap/core" "^2.0.3" + "@tiptap/extension-color" "^2.0.3" + "@tiptap/extension-highlight" "^2.0.3" + "@tiptap/extension-image" "^2.0.3" + "@tiptap/extension-link" "^2.0.3" + "@tiptap/extension-placeholder" "^2.0.3" + "@tiptap/extension-text-align" "^2.0.3" + "@tiptap/extension-text-style" "^2.0.3" + "@tiptap/extension-underline" "^2.0.3" + "@tiptap/pm" "^2.0.3" + "@tiptap/react" "^2.0.3" + "@tiptap/starter-kit" "^2.0.3" + clsx "^2.1.1" + ra-language-english@^4.16.11: version "4.16.11" resolved "https://registry.yarnpkg.com/ra-language-english/-/ra-language-english-4.16.11.tgz#93b82b27770b84339006927ae52600fcd763e7c8" From bd17ba30906be0b9f4102c0fc2f88922c7848c5d Mon Sep 17 00:00:00 2001 From: SlyRock Date: Wed, 27 Nov 2024 11:57:24 +0100 Subject: [PATCH 2/7] wip broadcast options : Style mentions in PostBlock.jsx fix carriage return replacement expression in Note.jsx --- .../src/common/blocks/ActivityBlock/Note.jsx | 6 +++-- frontend/src/common/blocks/PostBlock.jsx | 24 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/common/blocks/ActivityBlock/Note.jsx b/frontend/src/common/blocks/ActivityBlock/Note.jsx index 8b1cf60f9..268b88d18 100644 --- a/frontend/src/common/blocks/ActivityBlock/Note.jsx +++ b/frontend/src/common/blocks/ActivityBlock/Note.jsx @@ -29,7 +29,7 @@ const Note = ({ object, activity, clickOnContent }) => { } //Handle carriage return - content = content.replaceAll('\n', '
') + content = content?.replaceAll('\n', '
') // Find all mentions const mentions = arrayOf(object.tag || activity?.tag).filter(tag => tag.type === 'Mention'); @@ -37,7 +37,9 @@ const Note = ({ object, activity, clickOnContent }) => { if (mentions.length > 0) { // Replace mentions to local actor links content = content.replaceAll(mentionRegex, (match, actorUri, actorName) => { - const mention = mentions.find(mention => mention.name.startsWith(`@${actorName}@`)); + const mention = actorName.includes('@') + ? mentions.find(mention => mention.name.startsWith(`@${actorName}`)) + : mentions.find(mention => mention.name.startsWith(`@${actorName}@`)); if (mention) { return match.replace(actorUri, `/actor/${mention.name}`); } else { diff --git a/frontend/src/common/blocks/PostBlock.jsx b/frontend/src/common/blocks/PostBlock.jsx index b25ace118..47901cb0c 100644 --- a/frontend/src/common/blocks/PostBlock.jsx +++ b/frontend/src/common/blocks/PostBlock.jsx @@ -9,6 +9,7 @@ import { } from 'react-admin'; import { useLocation } from 'react-router-dom'; import { Card, Box, Button, IconButton, CircularProgress, Backdrop } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; import SendIcon from '@mui/icons-material/Send'; import InsertPhotoIcon from '@mui/icons-material/InsertPhoto'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -37,15 +38,20 @@ const PostBlock = ({ inReplyTo, mention }) => { const { data: identity } = useGetIdentity(); const [imageFiles, setImageFiles] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const theme = useTheme(); //List of mentionable actors const { items: followers } = useCollection('followers', { dereferenceItems: true }); const { items: following } = useCollection('following', { dereferenceItems: true }); - const mentionables = useMemo(() => sortBy(uniqBy([...followers, ...following], 'id'), 'preferredUsername').map((actor) => ({ - id: actor.id, - label: actor['preferredUsername'], - actor: actor - })), [followers, following]); + const mentionables = useMemo(() => sortBy(uniqBy([...followers, ...following], 'id'), 'preferredUsername').map((actor) => { + const instance = new URL(actor.id).host; + + return { + id: actor.id, + label: `${actor['preferredUsername']}@${instance}`, + actor: actor + } + }), [followers, following]); const suggestions = useMentions(mentionables); @@ -305,7 +311,7 @@ const PostBlock = ({ inReplyTo, mention }) => { border: 'none', fontSize: '16px', color: '#000', - padding: 0, + padding: 0 }, '& .tiptap.ProseMirror:hover': { backgroundColor: '#fff' @@ -313,6 +319,12 @@ const PostBlock = ({ inReplyTo, mention }) => { '& .tiptap.ProseMirror:focus': { backgroundColor: '#fff', border: 'none' + }, + //Styling the mentions + '& .tiptap.ProseMirror .mention': { + fontStyle: 'italic', + color: theme.palette.primary.main, + textDecoration: 'none', } }} fullWidth From 34423dca5a6833f2e3e6cb6ad6c3d86e243d8662 Mon Sep 17 00:00:00 2001 From: SlyRock Date: Wed, 27 Nov 2024 15:23:56 +0100 Subject: [PATCH 3/7] wip broadcast options : Visibility implemented and working --- frontend/src/common/blocks/PostBlock.jsx | 92 ++++++++++++++++-------- frontend/src/config/messages/en.js | 7 +- frontend/src/config/messages/fr.js | 7 +- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/frontend/src/common/blocks/PostBlock.jsx b/frontend/src/common/blocks/PostBlock.jsx index 47901cb0c..c6ad20538 100644 --- a/frontend/src/common/blocks/PostBlock.jsx +++ b/frontend/src/common/blocks/PostBlock.jsx @@ -5,7 +5,8 @@ import { useTranslate, useGetIdentity, useRedirect, - useDataProvider + useDataProvider, + SelectInput } from 'react-admin'; import { useLocation } from 'react-router-dom'; import { Card, Box, Button, IconButton, CircularProgress, Backdrop } from '@mui/material'; @@ -13,6 +14,7 @@ import { useTheme } from '@mui/material/styles'; import SendIcon from '@mui/icons-material/Send'; import InsertPhotoIcon from '@mui/icons-material/InsertPhoto'; import DeleteIcon from '@mui/icons-material/Delete'; +import PublicIcon from '@mui/icons-material/Public'; import { useOutbox, OBJECT_TYPES, @@ -45,12 +47,11 @@ const PostBlock = ({ inReplyTo, mention }) => { const { items: following } = useCollection('following', { dereferenceItems: true }); const mentionables = useMemo(() => sortBy(uniqBy([...followers, ...following], 'id'), 'preferredUsername').map((actor) => { const instance = new URL(actor.id).host; - return { id: actor.id, label: `${actor['preferredUsername']}@${instance}`, actor: actor - } + }; }), [followers, following]); const suggestions = useMentions(mentionables); @@ -152,23 +153,31 @@ const PostBlock = ({ inReplyTo, mention }) => { }).filter(Boolean); }, []); + const getRecipients = useCallback((mentionedUsersUris, values) => { + const recipients = mentionedUsersUris; + if (values.visibility === 'public') { + recipients.push(PUBLIC_URI, identity?.webIdData?.followers); + } else if (values.visibility === 'followers-only') { + recipients.push(identity?.webIdData?.followers); + } + if (mention) { + recipients.push(mention); + } + return recipients; + }, [identity, mention]); + const onSubmit = useCallback( async (values, { reset }) => { setIsSubmitting(true); try { const { processedContent, mentionedUsersUris } = processEditorContent(values.content); - const recipients = [PUBLIC_URI, identity?.webIdData?.followers, ...mentionedUsersUris]; - if (mention) { - recipients.push(mention); - } - const activity = { type: OBJECT_TYPES.NOTE, attributedTo: outbox.owner, content: processedContent, inReplyTo, - to: recipients + to: getRecipients(mentionedUsersUris, values) }; //handle attachments @@ -324,7 +333,7 @@ const PostBlock = ({ inReplyTo, mention }) => { '& .tiptap.ProseMirror .mention': { fontStyle: 'italic', color: theme.palette.primary.main, - textDecoration: 'none', + textDecoration: 'none' } }} fullWidth @@ -385,26 +394,51 @@ const PostBlock = ({ inReplyTo, mention }) => { )} - 0 ? 2 : 0}> - + + + setShowDialog(false)}> +
+ {translate('app.action.sendDirectMessage')} + + + + + + + +
+
+ + ); +}; + +export default SendDirectMessageButton; diff --git a/frontend/src/config/messages/en.js b/frontend/src/config/messages/en.js index 1bc1b4d8f..d8db24c31 100644 --- a/frontend/src/config/messages/en.js +++ b/frontend/src/config/messages/en.js @@ -12,7 +12,9 @@ export default { send: 'Send', reply: 'Reply', boost: 'Boost', - like: 'Like' + like: 'Like', + sendDirectMessage: 'Send a direct message', + message: 'Message' }, page: { my_inbox: 'My inbox', diff --git a/frontend/src/config/messages/fr.js b/frontend/src/config/messages/fr.js index bd1679d9c..34793ea19 100644 --- a/frontend/src/config/messages/fr.js +++ b/frontend/src/config/messages/fr.js @@ -12,7 +12,9 @@ export default { send: 'Envoyer', reply: 'Répondre', boost: 'Booster', - like: 'Soutenir' + like: 'Soutenir', + sendDirectMessage: 'Envoyer un message direct', + message: 'Message' }, page: { my_inbox: 'Boîte de réception', diff --git a/frontend/src/pages/ActorPage/Hero.jsx b/frontend/src/pages/ActorPage/Hero.jsx index 44af6e07c..f9b93e0c9 100644 --- a/frontend/src/pages/ActorPage/Hero.jsx +++ b/frontend/src/pages/ActorPage/Hero.jsx @@ -2,6 +2,7 @@ import { Box, Container, Avatar, Typography } from "@mui/material"; import makeStyles from "@mui/styles/makeStyles"; import FollowButton from "../../common/buttons/FollowButton"; import useActorContext from "../../hooks/useActorContext"; +import SendDirectMessageButton from '../../common/buttons/SendDirectMessageButton.jsx'; const useStyles = makeStyles((theme) => ({ root: { @@ -63,11 +64,24 @@ const Hero = () => { {actor?.username} )} - + + + + From 945f8774a1dde7679f9b9d32fba6d547309fd411 Mon Sep 17 00:00:00 2001 From: SlyRock Date: Mon, 2 Dec 2024 10:48:32 +0100 Subject: [PATCH 5/7] wip broadcast options : Direct messages : Add backdrop and spinner when submitting PostBlock.jsx : Adjust visibility selecter's design add i18n to MentionsList.jsx add comments to useMentions.js --- frontend/src/common/blocks/PostBlock.jsx | 36 +++++++++++++------ .../buttons/SendDirectMessageButton.jsx | 29 +++++++++++++-- frontend/src/config/messages/en.js | 3 +- frontend/src/config/messages/fr.js | 3 +- .../src/hooks/useMentions/MentionsList.jsx | 4 ++- frontend/src/hooks/useMentions/useMentions.js | 13 +++++++ 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/frontend/src/common/blocks/PostBlock.jsx b/frontend/src/common/blocks/PostBlock.jsx index c6ad20538..0464e46b9 100644 --- a/frontend/src/common/blocks/PostBlock.jsx +++ b/frontend/src/common/blocks/PostBlock.jsx @@ -9,12 +9,14 @@ import { SelectInput } from 'react-admin'; import { useLocation } from 'react-router-dom'; -import { Card, Box, Button, IconButton, CircularProgress, Backdrop } from '@mui/material'; +import { Card, Box, Button, IconButton, CircularProgress, Backdrop, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import SendIcon from '@mui/icons-material/Send'; import InsertPhotoIcon from '@mui/icons-material/InsertPhoto'; import DeleteIcon from '@mui/icons-material/Delete'; import PublicIcon from '@mui/icons-material/Public'; +import LockPersonIcon from '@mui/icons-material/LockPerson'; +import AlternateEmailIcon from '@mui/icons-material/AlternateEmail'; import { useOutbox, OBJECT_TYPES, @@ -394,11 +396,12 @@ const PostBlock = ({ inReplyTo, mention }) => { )} - 0 ? 2 : 0} flexWrap="wrap"> + 0 ? 2 : 0} + flexWrap="wrap"> } + label={false} source="visibility" defaultValue="public" parse={(value) => value || 'public'} //if empty, fallback to public @@ -407,18 +410,29 @@ const PostBlock = ({ inReplyTo, mention }) => { { id: 'followers-only', name: translate('app.visibility.followersOnly') }, { id: 'mentions-only', name: translate('app.visibility.mentionsOnly') } ]} + optionText={(choice) => ( + + {choice.id === 'public' && } + {choice.id === 'followers-only' && } + {choice.id === 'mentions-only' && } + + {choice.name} + + + )} + optionValue="id" sx={{ + //hide the legend element in the fieldset to avoid empty space in the border + '& .MuiOutlinedInput-notchedOutline legend': { + display: 'none' + }, + //adjust height and margin to align with buttons + '& .MuiOutlinedInput-notchedOutline': { + mt: '3px' + }, '& .MuiInputBase-root': { height: '36px', - padding: '0 12px', - display: 'flex', - alignItems: 'center', - }, - '& .MuiSelect-select': { - display: 'flex', - alignItems: 'center', }, - minWidth: '150px', }} /> setShowDialog(false)}> + theme.zIndex.drawer + 1, + backgroundColor: 'rgba(0, 0, 0, 0.1)', + borderRadius: 1 + }} + open={isSubmitting} + > + +
{translate('app.action.sendDirectMessage')} ({ @@ -30,6 +31,7 @@ const useStyles = makeStyles(theme => ({ export default forwardRef((props, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); const classes = useStyles(); + const translate = useTranslate(); const selectItem = index => { const item = props.items[index]; @@ -87,7 +89,7 @@ export default forwardRef((props, ref) => { )) ) : ( -
Aucun résultat
+
{translate('app.message.no_result')}
)} ); diff --git a/frontend/src/hooks/useMentions/useMentions.js b/frontend/src/hooks/useMentions/useMentions.js index cd23b8c9f..8bb6a91f9 100644 --- a/frontend/src/hooks/useMentions/useMentions.js +++ b/frontend/src/hooks/useMentions/useMentions.js @@ -1,7 +1,20 @@ import { useMemo } from 'react'; import renderMentions from './renderMentions'; +/** + * + * @param data The list of mentionable actors to be used in the mention suggestions. + * e.g. : { + * id: 'https://pod.provider/username', => the value that will be used in the "data-id" attribute of the generated link in the document + * label: '@username@pod.provider' => will be displayed in the document and stored in the "data-label" of the link + * } + * @returns {{items: (function({query: *}): *), render: ((function(): {onKeyDown(*): (boolean|*), onStart: function(*): void, onExit(): void, onUpdate(*): void})|*)}} + */ const useMentions = data => { + /** + * Filters suggestions using the end user query (what is typed after a "@" in the text field) + * @type {function({query: *}): *} + */ const items = useMemo( () => ({ query }) => data.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase())).slice(0, 5) , [data] From 0d8e00c389b45afed18ce08204e5e6be0dd4c47a Mon Sep 17 00:00:00 2001 From: SlyRock Date: Mon, 2 Dec 2024 11:23:59 +0100 Subject: [PATCH 6/7] Ready to review --- frontend/src/config/messages/fr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/config/messages/fr.js b/frontend/src/config/messages/fr.js index c2eee8725..0ce712ae7 100644 --- a/frontend/src/config/messages/fr.js +++ b/frontend/src/config/messages/fr.js @@ -55,7 +55,7 @@ export default { visibility: { public: 'Publique', followersOnly: 'Abonnés', - mentionsOnly: 'Personnes spécifique' + mentionsOnly: 'Personnes spécifiques' } } }; From d81b7c4aa07145fd6cb1e953bfd8c85d82545a85 Mon Sep 17 00:00:00 2001 From: SlyRock Date: Mon, 2 Dec 2024 17:16:16 +0100 Subject: [PATCH 7/7] Ready to merge last cosmetic fix on visibility selector --- frontend/src/common/blocks/PostBlock.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/common/blocks/PostBlock.jsx b/frontend/src/common/blocks/PostBlock.jsx index 0464e46b9..5273ff881 100644 --- a/frontend/src/common/blocks/PostBlock.jsx +++ b/frontend/src/common/blocks/PostBlock.jsx @@ -30,6 +30,7 @@ import Placeholder from '@tiptap/extension-placeholder'; import Mention from '@tiptap/extension-mention'; import { HardBreak } from '@tiptap/extension-hard-break'; import { uniqBy, sortBy } from 'lodash'; +import { required } from 'ra-core'; const PostBlock = ({ inReplyTo, mention }) => { const dataProvider = useDataProvider(); @@ -400,6 +401,7 @@ const PostBlock = ({ inReplyTo, mention }) => { flexWrap="wrap"> { ]} optionText={(choice) => ( - {choice.id === 'public' && } + {choice.id === 'public' && } {choice.id === 'followers-only' && } - {choice.id === 'mentions-only' && } + {choice.id === 'mentions-only' && } {choice.name}