diff --git a/src/actions/circle.ts b/src/actions/circle.ts index 8bd0e0092..a43a9fea9 100644 --- a/src/actions/circle.ts +++ b/src/actions/circle.ts @@ -8,6 +8,7 @@ import type { APIEntity } from 'soapbox/types/entities'; interface Interaction { acct: string; avatar?: string; + avatar_description?: string; replies: number; reblogs: number; favourites: number; @@ -49,6 +50,7 @@ const processCircle = (setProgress: (progress: { interaction.reblogs += 1; interaction.acct = status.reblog.account.acct; interaction.avatar = status.reblog.account.avatar_static || status.reblog.account.avatar; + interaction.avatar_description = status.reblog.account.avatar_description; } else if (status.in_reply_to_account_id) { if (status.in_reply_to_account_id === me) return; @@ -75,6 +77,7 @@ const processCircle = (setProgress: (progress: { interaction.favourites += 1; interaction.acct = status.account.acct; interaction.avatar = status.account.avatar_static || status.account.avatar; + interaction.avatar_description = status.account.avatar_description; }); return next; @@ -92,9 +95,9 @@ const processCircle = (setProgress: (progress: { if (!link) break; } - const result = await Promise.all(Object.entries(interactions).map(([id, { acct, avatar, favourites, reblogs, replies }]) => { + const result = await Promise.all(Object.entries(interactions).map(([id, { acct, avatar, avatar_description, favourites, reblogs, replies }]) => { const score = favourites + replies * 1.1 + reblogs * 1.3; - return { id, acct, avatar, score }; + return { id, acct, avatar, avatar_description, score }; }).toSorted((a, b) => b.score - a.score).slice(0, 49).map(async (interaction, index, array) => { setProgress({ state: 'fetchingAvatars', progress: 80 + (index / array.length) * 10 }); @@ -104,6 +107,7 @@ const processCircle = (setProgress: (progress: { interaction.acct = account.acct; interaction.avatar = account.avatar_static || account.avatar; + interaction.avatar_description = account.avatar_description; return interaction; })); diff --git a/src/components/account.tsx b/src/components/account.tsx index d171f3c5b..b408c7b42 100644 --- a/src/components/account.tsx +++ b/src/components/account.tsx @@ -196,7 +196,7 @@ const Account = ({
- + {emoji && ( {children}} > - + {emoji && ( , 'title' | 'className'> { + warning?: boolean; +} + +const AltIndicator: React.FC = ({ className, warning, ...props }) => ( + + {warning && } + + +); + +export default AltIndicator; diff --git a/src/components/avatar-stack.tsx b/src/components/avatar-stack.tsx index 70d514f1e..53fa79606 100644 --- a/src/components/avatar-stack.tsx +++ b/src/components/avatar-stack.tsx @@ -29,6 +29,7 @@ const AvatarStack: React.FC = ({ accountIds, limit = 3 }) => {
diff --git a/src/components/groups/group-avatar.tsx b/src/components/groups/group-avatar.tsx index 99cad4f71..6e444e181 100644 --- a/src/components/groups/group-avatar.tsx +++ b/src/components/groups/group-avatar.tsx @@ -29,6 +29,7 @@ const GroupAvatar = (props: IGroupAvatar) => { }) } src={group.avatar} + alt={group.avatar_description} size={size} /> ); diff --git a/src/components/ui/avatar/avatar.tsx b/src/components/ui/avatar/avatar.tsx index 448ab7e52..4a37c7bf9 100644 --- a/src/components/ui/avatar/avatar.tsx +++ b/src/components/ui/avatar/avatar.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import StillImage, { IStillImage } from 'soapbox/components/still-image'; @@ -7,14 +8,20 @@ import Icon from '../icon/icon'; const AVATAR_SIZE = 42; -interface IAvatar extends Pick { +const messages = defineMessages({ + avatar: { id: 'account.avatar.alt', defaultMessage: 'Avatar' }, +}); + +interface IAvatar extends Pick { /** Width and height of the avatar in pixels. */ size?: number; } /** Round profile avatar for accounts. */ const Avatar = (props: IAvatar) => { - const { src, size = AVATAR_SIZE, className } = props; + const intl = useIntl(); + + const { alt, src, size = AVATAR_SIZE, className } = props; const [isAvatarMissing, setIsAvatarMissing] = useState(false); @@ -47,7 +54,7 @@ const Avatar = (props: IAvatar) => { className={clsx('rounded-full', className)} style={style} src={src} - alt='Avatar' + alt={alt || intl.formatMessage(messages.avatar)} onError={handleLoadFailure} /> ); diff --git a/src/components/upload.tsx b/src/components/upload.tsx index ac97d62c0..fd3785117 100644 --- a/src/components/upload.tsx +++ b/src/components/upload.tsx @@ -10,10 +10,11 @@ import zoomInIcon from '@tabler/icons/outline/zoom-in.svg'; import clsx from 'clsx'; import { List as ImmutableList } from 'immutable'; import React, { useState } from 'react'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import { spring } from 'react-motion'; import { openModal } from 'soapbox/actions/modals'; +import AltIndicator from 'soapbox/components/alt-indicator'; import Blurhash from 'soapbox/components/blurhash'; import { HStack, Icon, IconButton } from 'soapbox/components/ui'; import Motion from 'soapbox/features/ui/util/optional-motion'; @@ -224,16 +225,14 @@ const Upload: React.FC = ({ )} {missingDescriptionModal && !description && ( - - - - + /> )}
diff --git a/src/features/account/components/header.tsx b/src/features/account/components/header.tsx index 30f78a8b0..a5adf9925 100644 --- a/src/features/account/components/header.tsx +++ b/src/features/account/components/header.tsx @@ -29,7 +29,7 @@ import { Account } from 'soapbox/schemas'; import toast from 'soapbox/toast'; import { isDefaultHeader } from 'soapbox/utils/accounts'; import copy from 'soapbox/utils/copy'; -import { MASTODON, parseVersion } from 'soapbox/utils/features'; +import { GOTOSOCIAL, MASTODON, parseVersion } from 'soapbox/utils/features'; import type { PlfeResponse } from 'soapbox/api'; @@ -284,7 +284,7 @@ const Header: React.FC = ({ account }) => { return []; } - if (!ownAccount && features.rssFeeds && account.local) { + if (features.rssFeeds && account.local && (software !== GOTOSOCIAL || account.enable_rss)) { menu.push({ text: intl.formatMessage(messages.subscribeFeed), icon: require('@tabler/icons/outline/rss.svg'), @@ -535,7 +535,7 @@ const Header: React.FC = ({ account }) => { header = ( ); @@ -592,7 +592,7 @@ const Header: React.FC = ({ account }) => { }; const renderRssButton = () => { - if (ownAccount || !features.rssFeeds || !account.local) { + if (ownAccount || !features.rssFeeds || !account.local || (software === GOTOSOCIAL && !account.enable_rss)) { return null; } @@ -637,6 +637,7 @@ const Header: React.FC = ({ account }) => { diff --git a/src/features/admin/components/report.tsx b/src/features/admin/components/report.tsx index f16f69fdc..860d5b71c 100644 --- a/src/features/admin/components/report.tsx +++ b/src/features/admin/components/report.tsx @@ -83,7 +83,12 @@ const Report: React.FC = ({ id }) => { - + diff --git a/src/features/chats/components/chat-list-item.tsx b/src/features/chats/components/chat-list-item.tsx index 93c395ce3..19a90dcb5 100644 --- a/src/features/chats/components/chat-list-item.tsx +++ b/src/features/chats/components/chat-list-item.tsx @@ -80,7 +80,12 @@ const ChatListItem: React.FC = ({ chat, onClick }) => { > - +
diff --git a/src/features/chats/components/chat-message-list.tsx b/src/features/chats/components/chat-message-list.tsx index d6bad1620..3f46ba1c6 100644 --- a/src/features/chats/components/chat-message-list.tsx +++ b/src/features/chats/components/chat-message-list.tsx @@ -179,7 +179,7 @@ const ChatMessageList: React.FC = ({ chat }) => { return ( - + <> {intl.formatMessage(messages.blockedBy)} diff --git a/src/features/chats/components/chat-page/components/chat-page-main.tsx b/src/features/chats/components/chat-page/components/chat-page-main.tsx index c5c35b7c3..e83705dac 100644 --- a/src/features/chats/components/chat-page/components/chat-page-main.tsx +++ b/src/features/chats/components/chat-page/components/chat-page-main.tsx @@ -112,7 +112,7 @@ const ChatPageMain = () => { /> - + @@ -139,7 +139,7 @@ const ChatPageMain = () => { - + {chat.account.display_name} @{chat.account.acct} diff --git a/src/features/chats/components/chat-search/results.tsx b/src/features/chats/components/chat-search/results.tsx index 28bef255d..c3ea7e67b 100644 --- a/src/features/chats/components/chat-search/results.tsx +++ b/src/features/chats/components/chat-search/results.tsx @@ -34,7 +34,7 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => { data-testid='account' > - +
diff --git a/src/features/chats/components/chat-widget/chat-settings.tsx b/src/features/chats/components/chat-widget/chat-settings.tsx index 937abdbd0..f4b7bf304 100644 --- a/src/features/chats/components/chat-widget/chat-settings.tsx +++ b/src/features/chats/components/chat-widget/chat-settings.tsx @@ -103,7 +103,7 @@ const ChatSettings = () => { - + {chat.account.display_name} @{chat.account.acct} diff --git a/src/features/chats/components/chat-widget/chat-window.tsx b/src/features/chats/components/chat-widget/chat-window.tsx index 26a38d567..66432f320 100644 --- a/src/features/chats/components/chat-widget/chat-window.tsx +++ b/src/features/chats/components/chat-widget/chat-window.tsx @@ -66,7 +66,7 @@ const ChatWindow = () => { {isOpen && ( - + )} diff --git a/src/features/circle/index.tsx b/src/features/circle/index.tsx index ac6a63b38..d3799d0b5 100644 --- a/src/features/circle/index.tsx +++ b/src/features/circle/index.tsx @@ -31,7 +31,7 @@ const Circle: React.FC = () => { progress: number; }>({ state: 'pending', progress: 0 }); const [expanded, setExpanded] = useState(false); - const [users, setUsers] = useState>(); + const [users, setUsers] = useState>(); const intl = useIntl(); const dispatch = useAppDispatch(); @@ -153,7 +153,7 @@ const Circle: React.FC = () => { {users?.map(user => ( - + {user.acct} diff --git a/src/features/directory/components/account-card.tsx b/src/features/directory/components/account-card.tsx index 514df696b..2bc2bf6c1 100644 --- a/src/features/directory/components/account-card.tsx +++ b/src/features/directory/components/account-card.tsx @@ -44,13 +44,18 @@ const AccountCard: React.FC = ({ id }) => { - +
diff --git a/src/features/edit-profile/components/avatar-picker.tsx b/src/features/edit-profile/components/avatar-picker.tsx index c45e8f500..50dd9f0be 100644 --- a/src/features/edit-profile/components/avatar-picker.tsx +++ b/src/features/edit-profile/components/avatar-picker.tsx @@ -1,9 +1,17 @@ import clsx from 'clsx'; import React, { useRef } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { openModal } from 'soapbox/actions/modals'; +import AltIndicator from 'soapbox/components/alt-indicator'; import { Avatar, Icon, HStack } from 'soapbox/components/ui'; -import { useDraggedFiles } from 'soapbox/hooks'; +import { useAppDispatch, useDraggedFiles } from 'soapbox/hooks'; + +const messages = defineMessages({ + changeDescriptionHeading: { id: 'group.upload_avatar.alt.heading', defaultMessage: 'Change avatar description' }, + changeDescriptionPlaceholder: { id: 'group.upload_avatar.alt.placeholder', defaultMessage: 'Image description' }, + changeDescriptionConfirm: { id: 'group.upload_avatar.alt.confirm', defaultMessage: 'Save' }, +}); interface IMediaInput { className?: string; @@ -11,21 +19,44 @@ interface IMediaInput { accept?: string; onChange: (files: FileList | null) => void; disabled?: boolean; + description?: string; + onChangeDescription?: (value: string) => void; } -const AvatarPicker = React.forwardRef(({ className, src, onChange, accept, disabled }, ref) => { +const AvatarPicker = React.forwardRef(({ + className, src, onChange, accept, disabled, description, onChangeDescription, +}, ref) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const picker = useRef(null); const { isDragging, isDraggedOver } = useDraggedFiles(picker, (files) => { onChange(files); }); + const handleChangeDescriptionClick: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + dispatch(openModal('TEXT_FIELD', { + heading: intl.formatMessage(messages.changeDescriptionHeading), + message: intl.formatMessage(messages.changeDescriptionPlaceholder), + confirm: intl.formatMessage(messages.changeDescriptionConfirm), + onConfirm: (description: string) => { + onChangeDescription?.(description); + }, + text: description, + })); + }; + return ( ); }); diff --git a/src/features/edit-profile/components/header-picker.tsx b/src/features/edit-profile/components/header-picker.tsx index bcf942142..63b88f38f 100644 --- a/src/features/edit-profile/components/header-picker.tsx +++ b/src/features/edit-profile/components/header-picker.tsx @@ -2,11 +2,16 @@ import clsx from 'clsx'; import React, { useRef } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { openModal } from 'soapbox/actions/modals'; +import AltIndicator from 'soapbox/components/alt-indicator'; import { HStack, Icon, IconButton, Text } from 'soapbox/components/ui'; -import { useDraggedFiles } from 'soapbox/hooks'; +import { useAppDispatch, useDraggedFiles } from 'soapbox/hooks'; const messages = defineMessages({ title: { id: 'group.upload_banner.title', defaultMessage: 'Upload background picture' }, + changeHeaderDescriptionHeading: { id: 'group.upload_banner.alt.heading', defaultMessage: 'Change header description' }, + changeHeaderDescriptionPlaceholder: { id: 'group.upload_banner.alt.placeholder', defaultMessage: 'Image description' }, + changeHeaderDescriptionConfirm: { id: 'group.upload_banner.alt.confirm', defaultMessage: 'Save' }, }); interface IMediaInput { @@ -15,9 +20,14 @@ interface IMediaInput { onChange: (files: FileList | null) => void; onClear?: () => void; disabled?: boolean; + description?: string; + onChangeDescription?: (value: string) => void; } -const HeaderPicker = React.forwardRef(({ src, onChange, onClear, accept, disabled }, ref) => { +const HeaderPicker = React.forwardRef(({ + src, onChange, onClear, accept, disabled, description, onChangeDescription, +}, ref) => { + const dispatch = useAppDispatch(); const intl = useIntl(); const picker = useRef(null); @@ -32,6 +42,20 @@ const HeaderPicker = React.forwardRef(({ src, onC onClear!(); }; + const handleChangeDescriptionClick: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + dispatch(openModal('TEXT_FIELD', { + heading: intl.formatMessage(messages.changeHeaderDescriptionHeading), + message: intl.formatMessage(messages.changeHeaderDescriptionPlaceholder), + confirm: intl.formatMessage(messages.changeHeaderDescriptionConfirm), + onConfirm: (description: string) => { + onChangeDescription?.(description); + }, + text: description, + })); + }; + return ( ); }); diff --git a/src/features/edit-profile/index.tsx b/src/features/edit-profile/index.tsx index 45a73355f..faf27797d 100644 --- a/src/features/edit-profile/index.tsx +++ b/src/features/edit-profile/index.tsx @@ -1,3 +1,4 @@ +import pick from 'lodash/pick'; import React, { useState, useEffect } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; @@ -21,6 +22,7 @@ import { useAppDispatch, useOwnAccount, useFeatures, useInstance, useAppSelector import { useImageField } from 'soapbox/hooks/forms'; import toast from 'soapbox/toast'; import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; +import { GOTOSOCIAL, parseVersion } from 'soapbox/utils/features'; import AvatarPicker from './components/avatar-picker'; import HeaderPicker from './components/header-picker'; @@ -112,6 +114,12 @@ interface AccountCredentials { location?: string; /** User's birthday. */ birthday?: string; + /** GoToSocial: Avatar image description. */ + avatar_description?: string; + /** GoToSocial: Header image description. */ + header_description?: string; + /** GoToSocial: Enable RSS feed for public posts */ + enable_rss?: boolean; } /** Convert an account into an update_credentials request object. */ @@ -119,11 +127,8 @@ const accountToCredentials = (account: Account): AccountCredentials => { const hideNetwork = hidesNetwork(account); return { - discoverable: account.discoverable, - bot: account.bot, - display_name: account.display_name, + ...(pick(account, ['discoverable', 'bot', 'display_name', 'locked', 'location', 'avatar_description', 'header_description', 'enable_rss'])), note: account.source?.note ?? '', - locked: account.locked, fields_attributes: [...account.source?.fields ?? []], stranger_notifications: account.pleroma?.notification_settings?.block_from_strangers === true, accepts_email_list: account.pleroma?.accepts_email_list === true, @@ -131,7 +136,6 @@ const accountToCredentials = (account: Account): AccountCredentials => { hide_follows: hideNetwork, hide_followers_count: hideNetwork, hide_follows_count: hideNetwork, - location: account.location, birthday: account.pleroma?.birthday ?? undefined, }; }; @@ -168,10 +172,13 @@ const EditProfile: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); const instance = useInstance(); + const { software } = parseVersion(instance.version); const { account } = useOwnAccount(); const features = useFeatures(); - const maxFields = instance.pleroma.metadata.fields_limits.max_fields; + const maxFields = instance.configuration.accounts + ? instance.configuration.accounts.max_profile_fields + : instance.pleroma.metadata.fields_limits.max_fields; const attachmentTypes = useAppSelector( state => state.instance.configuration.media_attachments.supported_mime_types) @@ -230,6 +237,10 @@ const EditProfile: React.FC = () => { event.preventDefault(); }; + const handleFieldChange = (key: keyof AccountCredentials) => (value: T) => { + updateData(key, value); + }; + const handleCheckboxChange = (key: keyof AccountCredentials): React.ChangeEventHandler => e => { updateData(key, e.target.checked); }; @@ -270,12 +281,30 @@ const EditProfile: React.FC = () => { updateData('fields_attributes', fields); }; + const handleAvatarChangeDescription = features.accountAvatarDescription + ? handleFieldChange('avatar_description') : undefined; + const handleHeaderChangeDescription = features.accountAvatarDescription + ? handleFieldChange('header_description') : undefined; + return (
- - + +
{ /> )} + + {features.rssFeeds && software === GOTOSOCIAL && ( + } + > + + + )} {features.profileFields && ( diff --git a/src/features/group/group-timeline.tsx b/src/features/group/group-timeline.tsx index 75709d856..d04c904d1 100644 --- a/src/features/group/group-timeline.tsx +++ b/src/features/group/group-timeline.tsx @@ -69,7 +69,7 @@ const GroupTimeline: React.FC = (props) => { })} > - + void }) => {
{account && ( - + )} {isSubmitting && ( diff --git a/src/features/onboarding/steps/cover-photo-selection-step.tsx b/src/features/onboarding/steps/cover-photo-selection-step.tsx index a77e5a6c6..d3c160955 100644 --- a/src/features/onboarding/steps/cover-photo-selection-step.tsx +++ b/src/features/onboarding/steps/cover-photo-selection-step.tsx @@ -81,7 +81,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => { {selectedFile || account?.header && ( )} @@ -111,7 +111,12 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
{account && ( - + )} {account?.display_name} diff --git a/src/features/ui/components/compose-button.tsx b/src/features/ui/components/compose-button.tsx index 1994899bd..051ab028e 100644 --- a/src/features/ui/components/compose-button.tsx +++ b/src/features/ui/components/compose-button.tsx @@ -57,7 +57,7 @@ const GroupComposeButton = () => { block > - + diff --git a/src/features/ui/components/floating-action-button.tsx b/src/features/ui/components/floating-action-button.tsx index 1254d7d5a..b8af9abc4 100644 --- a/src/features/ui/components/floating-action-button.tsx +++ b/src/features/ui/components/floating-action-button.tsx @@ -72,7 +72,7 @@ const GroupFAB: React.FC = () => { aria-label={intl.formatMessage(messages.publish)} > - + > = { 'REPLY_MENTIONS': ReplyMentionsModal, 'REPORT': ReportModal, 'SELECT_BOOKMARK_FOLDER': SelectBookmarkFolderModal, + 'TEXT_FIELD': TextFieldModal, 'UNAUTHORIZED': UnauthorizedModal, 'VIDEO': VideoModal, }; diff --git a/src/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx b/src/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx index 63398bf2a..e9fa416ee 100644 --- a/src/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx +++ b/src/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx @@ -48,7 +48,7 @@ const ConfirmationStep: React.FC = ({ group }) => { diff --git a/src/features/ui/components/modals/text-field-modal.tsx b/src/features/ui/components/modals/text-field-modal.tsx new file mode 100644 index 000000000..d27809f2f --- /dev/null +++ b/src/features/ui/components/modals/text-field-modal.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Modal, Stack, Textarea } from 'soapbox/components/ui'; + +import type { ButtonThemes } from 'soapbox/components/ui/button/useButtonStyles'; + +interface ITextFieldModal { + heading: React.ReactNode; + placeholder?: string; + confirm: React.ReactNode; + onClose: (type: string) => void; + onConfirm: (value?: string) => void; + onCancel: () => void; + confirmationTheme?: ButtonThemes; + text?: string; +} + +const TextFieldModal: React.FC = ({ + heading, + placeholder, + confirm, + onClose, + onConfirm, + onCancel, + confirmationTheme, + text, +}) => { + const [value, setValue] = useState(text); + + const handleClick = () => { + onClose('CONFIRM'); + onConfirm(value); + }; + + const handleCancel = () => { + onClose('CONFIRM'); + if (onCancel) onCancel(); + }; + + return ( + } + cancelAction={handleCancel} + > + +