diff --git a/CHANGELOG.md b/CHANGELOG.md index afca6bb71..0e04be802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Posts: Support posts filtering on recent Mastodon versions +- Reactions: Support custom emoji reactions +- Compatbility: Support Mastodon v2 timeline filters. ### Changed - Posts: truncate Nostr pubkeys in reply mentions. +- Posts: upgraded emoji picker component. ### Fixed - Posts: fixed emojis being cut off in reactions modal. - Posts: fix audio player progress bar visibility. +- Posts: added missing gap in pending status. +- Compatibility: fixed quote posting compatibility with custom Pleroma forks. +- Profile: fix "load more" button height on account gallery page. +- 18n: fixed Chinese language being detected from the browser. +- Conversations: fixed pagination (Mastodon). ## [3.2.0] - 2023-02-15 diff --git a/app/assets/images/group.svg b/app/assets/images/group.svg deleted file mode 100644 index 4e187999d..000000000 --- a/app/assets/images/group.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/soapbox/actions/emoji-reacts.ts b/app/soapbox/actions/emoji-reacts.ts index ac205d38d..746a7372f 100644 --- a/app/soapbox/actions/emoji-reacts.ts +++ b/app/soapbox/actions/emoji-reacts.ts @@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL'; const noOp = () => () => new Promise(f => f(undefined)); -const simpleEmojiReact = (status: Status, emoji: string) => +const simpleEmojiReact = (status: Status, emoji: string, custom?: string) => (dispatch: AppDispatch) => { const emojiReacts: ImmutableList> = status.pleroma.get('emoji_reactions') || ImmutableList(); @@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string) => if (emoji === 'πŸ‘') { dispatch(favourite(status)); } else { - dispatch(emojiReact(status, emoji)); + dispatch(emojiReact(status, emoji, custom)); } }).catch(err => { console.error(err); @@ -70,11 +70,11 @@ const fetchEmojiReacts = (id: string, emoji: string) => }); }; -const emojiReact = (status: Status, emoji: string) => +const emojiReact = (status: Status, emoji: string, custom?: string) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return dispatch(noOp()); - dispatch(emojiReactRequest(status, emoji)); + dispatch(emojiReactRequest(status, emoji, custom)); return api(getState) .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) @@ -120,10 +120,11 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({ error, }); -const emojiReactRequest = (status: Status, emoji: string) => ({ +const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({ type: EMOJI_REACT_REQUEST, status, emoji, + custom, skipLoading: true, }); diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index b97d52f20..d78e7f5d8 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -1,5 +1,6 @@ import { defineMessages } from 'react-intl'; +import { deleteEntities } from 'soapbox/entity-store/actions'; import toast from 'soapbox/toast'; import api, { getLinks } from '../api'; @@ -191,7 +192,7 @@ const updateGroupFail = (error: AxiosError) => ({ }); const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(deleteGroupRequest(id)); + dispatch(deleteEntities([id], 'Group')); return api(getState).delete(`/api/v1/groups/${id}`) .then(() => dispatch(deleteGroupSuccess(id))) diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index 8750a5d61..ec0ec3121 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -1,3 +1,8 @@ +import { importEntities } from 'soapbox/entity-store/actions'; +import { Entities } from 'soapbox/entity-store/entities'; +import { Group, groupSchema } from 'soapbox/schemas'; +import { filteredArray } from 'soapbox/schemas/utils'; + import { getSettings } from '../settings'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -18,11 +23,11 @@ const importAccount = (account: APIEntity) => const importAccounts = (accounts: APIEntity[]) => ({ type: ACCOUNTS_IMPORT, accounts }); -const importGroup = (group: APIEntity) => - ({ type: GROUP_IMPORT, group }); +const importGroup = (group: Group) => + importEntities([group], Entities.GROUPS); -const importGroups = (groups: APIEntity[]) => - ({ type: GROUPS_IMPORT, groups }); +const importGroups = (groups: Group[]) => + importEntities(groups, Entities.GROUPS); const importStatus = (status: APIEntity, idempotencyKey?: string) => (dispatch: AppDispatch, getState: () => RootState) => { @@ -69,17 +74,8 @@ const importFetchedGroup = (group: APIEntity) => importFetchedGroups([group]); const importFetchedGroups = (groups: APIEntity[]) => { - const normalGroups: APIEntity[] = []; - - const processGroup = (group: APIEntity) => { - if (!group.id) return; - - normalGroups.push(group); - }; - - groups.forEach(processGroup); - - return importGroups(normalGroups); + const entities = filteredArray(groupSchema).catch([]).parse(groups); + return importGroups(entities); }; const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index d1fb9352f..e3c19a857 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -91,6 +91,7 @@ export interface IAccount { showEdit?: boolean approvalStatus?: StatusApprovalStatus emoji?: string + emojiUrl?: string note?: string } @@ -116,6 +117,7 @@ const Account = ({ showEdit = false, approvalStatus, emoji, + emojiUrl, note, }: IAccount) => { const overflowRef = useRef(null); @@ -193,6 +195,7 @@ const Account = ({ )} diff --git a/app/soapbox/components/groups/group-avatar.tsx b/app/soapbox/components/groups/group-avatar.tsx index b862a92c0..91d6808d2 100644 --- a/app/soapbox/components/groups/group-avatar.tsx +++ b/app/soapbox/components/groups/group-avatar.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import React from 'react'; -import { useGroupRoles } from 'soapbox/hooks/useGroupRoles'; +import { GroupRoles } from 'soapbox/schemas/group-member'; import { Avatar } from '../ui'; @@ -16,17 +16,15 @@ interface IGroupAvatar { const GroupAvatar = (props: IGroupAvatar) => { const { group, size, withRing = false } = props; - const { normalizeRole } = useGroupRoles(); - - const isAdmin = normalizeRole(group.relationship?.role as any) === 'admin'; + const isOwner = group.relationship?.role === GroupRoles.OWNER; return ( = ({ allowedEmoji, ).reduce((acc, cur) => acc + cur.get('count'), 0); - const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; + const meEmojiReact = getReactForStatus(status, allowedEmoji); + const meEmojiName = meEmojiReact?.get('name') as keyof typeof reactMessages | undefined; const reactMessages = { 'πŸ‘': messages.reactionLike, @@ -550,7 +551,7 @@ const StatusActionBar: React.FC = ({ '': messages.favourite, }; - const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); + const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite); const menu = _makeMenu(publicStatus); let reblogIcon = require('@tabler/icons/repeat.svg'); @@ -635,7 +636,7 @@ const StatusActionBar: React.FC = ({ icon={require('@tabler/icons/heart.svg')} filled color='accent' - active={Boolean(meEmojiReact)} + active={Boolean(meEmojiName)} count={emojiReactCount} emoji={meEmojiReact} text={withLabels ? meEmojiTitle : undefined} @@ -648,7 +649,7 @@ const StatusActionBar: React.FC = ({ color='accent' filled onClick={handleFavouriteClick} - active={Boolean(meEmojiReact)} + active={Boolean(meEmojiName)} count={favouriteCount} text={withLabels ? meEmojiTitle : undefined} /> diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 6fe7130f6..39795fc7e 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -4,6 +4,8 @@ import React from 'react'; import { Text, Icon, Emoji } from 'soapbox/components/ui'; import { shortNumberFormat } from 'soapbox/utils/numbers'; +import type { Map as ImmutableMap } from 'immutable'; + const COLORS = { accent: 'accent', success: 'success', @@ -31,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes text?: React.ReactNode } @@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef - + ); } else { diff --git a/app/soapbox/components/status-reaction-wrapper.tsx b/app/soapbox/components/status-reaction-wrapper.tsx index b95d98375..206cd1fed 100644 --- a/app/soapbox/components/status-reaction-wrapper.tsx +++ b/app/soapbox/components/status-reaction-wrapper.tsx @@ -60,9 +60,9 @@ const StatusReactionWrapper: React.FC = ({ statusId, chi } }; - const handleReact = (emoji: string): void => { + const handleReact = (emoji: string, custom?: string): void => { if (ownAccount) { - dispatch(simpleEmojiReact(status, emoji)); + dispatch(simpleEmojiReact(status, emoji, custom)); } else { handleUnauthorized(); } @@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC = ({ statusId, chi }; const handleClick: React.EventHandler = e => { - const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || 'πŸ‘'; + const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || 'πŸ‘'; if (isUserTouching()) { if (ownAccount) { diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx index 961155cb3..a4485e79f 100644 --- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -1,13 +1,12 @@ -import { Placement } from '@popperjs/core'; +import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react'; import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; -import { usePopper } from 'react-popper'; import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui'; import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown'; -import { useSoapboxConfig } from 'soapbox/hooks'; +import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; -import type { Emoji, NativeEmoji } from 'soapbox/features/emoji'; +import type { Emoji } from 'soapbox/features/emoji'; interface IEmojiButton { /** Unicode emoji character. */ @@ -39,14 +38,13 @@ const EmojiButton: React.FC = ({ emoji, className, onClick, tabInd interface IEmojiSelector { onClose?(): void /** Event handler when an emoji is clicked. */ - onReact(emoji: string): void + onReact(emoji: string, custom?: string): void /** Element that triggers the EmojiSelector Popper */ referenceElement: HTMLElement | null placement?: Placement /** Whether the selector should be visible. */ visible?: boolean - /** X/Y offset of the floating picker. */ - offset?: [number, number] + offsetOptions?: OffsetOptions /** Whether to allow any emoji to be chosen. */ all?: boolean } @@ -58,43 +56,17 @@ const EmojiSelector: React.FC = ({ onReact, placement = 'top', visible = false, - offset = [-10, 0], + offsetOptions, all = true, }): JSX.Element => { const soapboxConfig = useSoapboxConfig(); + const { customEmojiReacts } = useFeatures(); const [expanded, setExpanded] = useState(false); - // `useRef` won't trigger a re-render, while `useState` does. - // https://popper.js.org/react-popper/v2/ - const [popperElement, setPopperElement] = useState(null); - - const handleClickOutside = (event: MouseEvent) => { - if ([referenceElement, popperElement, document.querySelector('em-emoji-picker')].some(el => el?.contains(event.target as Node))) { - return; - } - - if (document.querySelector('em-emoji-picker')) { - event.preventDefault(); - event.stopPropagation(); - return setExpanded(false); - } - - if (onClose) { - onClose(); - } - }; - - const { styles, attributes, update } = usePopper(referenceElement, popperElement, { + const { x, y, strategy, refs, update } = useFloating({ placement, - modifiers: [ - { - name: 'offset', - options: { - offset, - }, - }, - ], + middleware: [offset(offsetOptions), shift()], }); const handleExpand: React.MouseEventHandler = () => { @@ -102,9 +74,13 @@ const EmojiSelector: React.FC = ({ }; const handlePickEmoji = (emoji: Emoji) => { - onReact((emoji as NativeEmoji).native); + onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined); }; + useEffect(() => { + refs.setReference(referenceElement); + }, [referenceElement]); + useEffect(() => () => { document.body.style.overflow = ''; }, []); @@ -113,42 +89,31 @@ const EmojiSelector: React.FC = ({ setExpanded(false); }, [visible]); - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [referenceElement, popperElement]); - - useEffect(() => { - if (visible && update) { - update(); + useClickOutside(refs, () => { + if (onClose) { + onClose(); } - }, [visible, update]); - - useEffect(() => { - if (expanded && update) { - update(); - } - }, [expanded, update]); - + }); return (
{expanded ? ( ) : ( diff --git a/app/soapbox/components/ui/emoji/emoji.tsx b/app/soapbox/components/ui/emoji/emoji.tsx index b631f230d..eb8f02509 100644 --- a/app/soapbox/components/ui/emoji/emoji.tsx +++ b/app/soapbox/components/ui/emoji/emoji.tsx @@ -10,7 +10,7 @@ interface IEmoji extends React.ImgHTMLAttributes { /** A single emoji image. */ const Emoji: React.FC = (props): JSX.Element | null => { - const { emoji, alt, ...rest } = props; + const { emoji, alt, src, ...rest } = props; const codepoints = toCodePoints(removeVS16s(emoji)); const filename = codepoints.join('-'); @@ -20,7 +20,7 @@ const Emoji: React.FC = (props): JSX.Element | null => { {alt ); diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 05d7b60d9..30220eed6 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -2,6 +2,4 @@ export enum Entities { GROUPS = 'Groups', GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', - POPULAR_GROUPS = 'PopularGroups', - SUGGESTED_GROUPS = 'SuggestedGroups', } \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 2b307afde..3a60f4627 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { z } from 'zod'; import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; @@ -28,6 +29,10 @@ interface EntityActionEndpoints { delete?: string } +interface EntityCallbacks { + onSuccess?(entity?: TEntity): void +} + function useEntityActions( path: EntityPath, endpoints: EntityActionEndpoints, @@ -38,9 +43,13 @@ function useEntityActions( const getState = useGetState(); const [entityType, listKey] = path; - function createEntity(params: P): Promise> { + const [isLoading, setIsLoading] = useState(false); + + function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise> { if (!endpoints.post) return Promise.reject(endpoints); + setIsLoading(true); + return api.post(endpoints.post, params).then((response) => { const schema = opts.schema || z.custom(); const entity = schema.parse(response.data); @@ -48,6 +57,12 @@ function useEntityActions( // TODO: optimistic updating dispatch(importEntities([entity], entityType, listKey)); + if (callbacks.onSuccess) { + callbacks.onSuccess(entity); + } + + setIsLoading(false); + return { response, entity, @@ -55,14 +70,20 @@ function useEntityActions( }); } - function deleteEntity(entityId: string): Promise { + function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { if (!endpoints.delete) return Promise.reject(endpoints); // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; // Optimistically delete the entity from the _store_ but keep the lists in tact. dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); + setIsLoading(true); + return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => { + if (callbacks.onSuccess) { + callbacks.onSuccess(); + } + // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); @@ -75,12 +96,15 @@ function useEntityActions( dispatch(importEntities([entity], entityType)); } throw e; + }).finally(() => { + setIsLoading(false); }); } return { - createEntity: createEntity, - deleteEntity: endpoints.delete ? deleteEntity : undefined, + createEntity, + deleteEntity, + isLoading, }; } diff --git a/app/soapbox/features/chats/components/chat-composer.tsx b/app/soapbox/features/chats/components/chat-composer.tsx index 11b22ba5d..c5e4900e2 100644 --- a/app/soapbox/features/chats/components/chat-composer.tsx +++ b/app/soapbox/features/chats/components/chat-composer.tsx @@ -110,7 +110,7 @@ const ChatComposer = React.forwardRef ); if (token && tokenStart) { - const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); setSuggestions({ list: results, token, diff --git a/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx b/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx index 03cbf23a7..91e959f7c 100644 --- a/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx +++ b/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx @@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) { referenceElement={referenceElement} onReact={handleSelect} onClose={() => setIsOpen(false)} - offset={[-10, 12]} + offsetOptions={{ mainAxis: 12, crossAxis: -10 }} all={false} /> diff --git a/app/soapbox/features/chats/components/chat-textarea.tsx b/app/soapbox/features/chats/components/chat-textarea.tsx index 111a4cdc9..f6ee67b93 100644 --- a/app/soapbox/features/chats/components/chat-textarea.tsx +++ b/app/soapbox/features/chats/components/chat-textarea.tsx @@ -14,13 +14,13 @@ interface IChatTextarea extends React.ComponentProps { } /** Custom textarea for chats. */ -const ChatTextarea: React.FC = ({ +const ChatTextarea: React.FC = React.forwardRef(({ attachments, onDeleteAttachment, uploadCount = 0, uploadProgress = 0, ...rest -}) => { +}, ref) => { const isUploading = uploadCount > 0; const handleDeleteAttachment = (i: number) => { @@ -64,9 +64,9 @@ const ChatTextarea: React.FC = ({ )} -