diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d312320..43ca09934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compatbility: Preliminary support for Ditto backend. - Posts: Support dislikes on Friendica. - UI: added a character counter to some textareas. +- UI: added new experience for viewing Media ### Changed - Posts: truncate Nostr pubkeys in reply mentions. @@ -34,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compatibility: fix version parsing for Friendica. - UI: fixed various overflow issues related to long usernames. - UI: fixed display of Markdown code blocks in the reply indicator. +- Auth: fixed too many API requests when the server has an error. ## [3.2.0] - 2023-02-15 diff --git a/app/soapbox/__fixtures__/group-truthsocial.json b/app/soapbox/__fixtures__/group-truthsocial.json index f874f6892..63d8b14d5 100644 --- a/app/soapbox/__fixtures__/group-truthsocial.json +++ b/app/soapbox/__fixtures__/group-truthsocial.json @@ -1,16 +1,19 @@ { - "note": "patriots 900000001", - "discoverable": true, - "id": "109989480368015378", - "domain": null, "avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg", "avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg", + "created_at": "2023-03-08T00:00:00.000Z", + "discoverable": true, + "display_name": "PATRIOT PATRIOTS", + "domain": null, + "group_visibility": "everyone", "header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png", "header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png", - "group_visibility": "everyone", - "created_at": "2023-03-08T00:00:00.000Z", - "display_name": "PATRIOT PATRIOTS", - "membership_required": true, + "id": "109989480368015378", "members_count": 1, + "membership_required": true, + "note": "patriots 900000001", + "owner": { + "id": "424023483294040" + }, "tags": [] } \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts index a00a9d877..dc4eac6f3 100644 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ b/app/soapbox/actions/__tests__/account-notes.test.ts @@ -81,6 +81,7 @@ describe('initAccountNoteModal()', () => { }) as Account; const expectedActions = [ { type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' }, + { type: 'MODAL_CLOSE', modalType: 'ACCOUNT_NOTE' }, { type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' }, ]; await store.dispatch(initAccountNoteModal(account)); diff --git a/app/soapbox/actions/__tests__/statuses.test.ts b/app/soapbox/actions/__tests__/statuses.test.ts index 68af7608f..8b057802d 100644 --- a/app/soapbox/actions/__tests__/statuses.test.ts +++ b/app/soapbox/actions/__tests__/statuses.test.ts @@ -123,6 +123,7 @@ describe('deleteStatus()', () => { withRedraft: true, id: 'compose-modal', }, + { type: 'MODAL_CLOSE', modalType: 'COMPOSE', modalProps: undefined }, { type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined }, ]; await store.dispatch(deleteStatus(statusId, true)); diff --git a/app/soapbox/actions/account-notes.ts b/app/soapbox/actions/account-notes.ts index 33391cff4..2d0c0cb13 100644 --- a/app/soapbox/actions/account-notes.ts +++ b/app/soapbox/actions/account-notes.ts @@ -4,7 +4,7 @@ import { openModal, closeModal } from './modals'; import type { AxiosError } from 'axios'; import type { AnyAction } from 'redux'; -import type { RootState } from 'soapbox/store'; +import type { AppDispatch, RootState } from 'soapbox/store'; import type { Account } from 'soapbox/types/entities'; const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; @@ -51,7 +51,7 @@ function submitAccountNoteFail(error: AxiosError) { }; } -const initAccountNoteModal = (account: Account) => (dispatch: React.Dispatch, getState: () => RootState) => { +const initAccountNoteModal = (account: Account) => (dispatch: AppDispatch, getState: () => RootState) => { const comment = getState().relationships.get(account.id)!.note; dispatch({ diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index e4a10edd0..8c4e550e9 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -178,8 +178,7 @@ export const rememberAuthAccount = (accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) - .then(() => dispatch(verifyCredentials(token, accountUrl))) - .catch(() => dispatch(verifyCredentials(token, accountUrl))); + .finally(() => dispatch(verifyCredentials(token, accountUrl))); export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { diff --git a/app/soapbox/actions/blocks.ts b/app/soapbox/actions/blocks.ts index d3f625884..ef2f40359 100644 --- a/app/soapbox/actions/blocks.ts +++ b/app/soapbox/actions/blocks.ts @@ -6,9 +6,8 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; -import type { AnyAction } from '@reduxjs/toolkit'; import type { AxiosError } from 'axios'; -import type { RootState } from 'soapbox/store'; +import type { AppDispatch, RootState } from 'soapbox/store'; const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; @@ -18,7 +17,7 @@ const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; -const fetchBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { +const fetchBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; const nextLinkName = getNextLinkName(getState); @@ -54,7 +53,7 @@ function fetchBlocksFail(error: AxiosError) { }; } -const expandBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { +const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; const nextLinkName = getNextLinkName(getState); diff --git a/app/soapbox/actions/familiar-followers.ts b/app/soapbox/actions/familiar-followers.ts index 2d8aa6786..2c82126c6 100644 --- a/app/soapbox/actions/familiar-followers.ts +++ b/app/soapbox/actions/familiar-followers.ts @@ -1,40 +1,14 @@ -import { RootState } from 'soapbox/store'; +import { AppDispatch, RootState } from 'soapbox/store'; import api from '../api'; -import { ACCOUNTS_IMPORT, importFetchedAccounts } from './importer'; - -import type { APIEntity } from 'soapbox/types/entities'; +import { importFetchedAccounts } from './importer'; export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST'; export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS'; export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL'; -type FamiliarFollowersFetchRequestAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST - id: string -} - -type FamiliarFollowersFetchRequestSuccessAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS - id: string - accounts: Array -} - -type FamiliarFollowersFetchRequestFailAction = { - type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL - id: string - error: any -} - -type AccountsImportAction = { - type: typeof ACCOUNTS_IMPORT - accounts: Array -} - -export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction - -export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch, getState: () => RootState) => { +export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FAMILIAR_FOLLOWERS_FETCH_REQUEST, id: accountId, @@ -44,7 +18,7 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: R .then(({ data }) => { const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts; - dispatch(importFetchedAccounts(accounts) as AccountsImportAction); + dispatch(importFetchedAccounts(accounts)); dispatch({ type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS, id: accountId, diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 40d981139..77bccb41f 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -7,10 +7,11 @@ import api from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { expandGroupFeaturedTimeline } from './timelines'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; +import type { APIEntity, Group, Status as StatusEntity } from 'soapbox/types/entities'; const REBLOG_REQUEST = 'REBLOG_REQUEST'; const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -160,7 +161,7 @@ const favourite = (status: StatusEntity) => dispatch(favouriteRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) { + api(getState).post(`/api/v1/statuses/${status.id}/favourite`).then(function(response) { dispatch(favouriteSuccess(status)); }).catch(function(error) { dispatch(favouriteFail(status, error)); @@ -173,7 +174,7 @@ const unfavourite = (status: StatusEntity) => dispatch(unfavouriteRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(() => { + api(getState).post(`/api/v1/statuses/${status.id}/unfavourite`).then(() => { dispatch(unfavouriteSuccess(status)); }).catch(error => { dispatch(unfavouriteFail(status, error)); @@ -511,6 +512,20 @@ const pin = (status: StatusEntity) => }); }; +const pinToGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/pin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + +const unpinFromGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/unpin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + const pinRequest = (status: StatusEntity) => ({ type: PIN_REQUEST, status, @@ -715,6 +730,8 @@ export { unpinSuccess, unpinFail, togglePin, + pinToGroup, + unpinFromGroup, remoteInteraction, remoteInteractionRequest, remoteInteractionSuccess, diff --git a/app/soapbox/actions/modals.ts b/app/soapbox/actions/modals.ts index 83b52cb3e..20ae13f0a 100644 --- a/app/soapbox/actions/modals.ts +++ b/app/soapbox/actions/modals.ts @@ -1,3 +1,5 @@ +import { AppDispatch } from 'soapbox/store'; + import type { ModalType } from 'soapbox/features/ui/components/modal-root'; export const MODAL_OPEN = 'MODAL_OPEN'; @@ -5,13 +7,18 @@ export const MODAL_CLOSE = 'MODAL_CLOSE'; /** Open a modal of the given type */ export function openModal(type: ModalType, props?: any) { - return { - type: MODAL_OPEN, - modalType: type, - modalProps: props, + return (dispatch: AppDispatch) => { + dispatch(closeModal(type)); + dispatch(openModalSuccess(type, props)); }; } +const openModalSuccess = (type: ModalType, props?: any) => ({ + type: MODAL_OPEN, + modalType: type, + modalProps: props, +}); + /** Close the modal */ export function closeModal(type?: ModalType) { return { diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index a2f165ac0..3f8d2011e 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -119,17 +119,22 @@ const setFilter = (filterType: SearchFilter) => }; const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { - const value = getState().search.value; - const offset = getState().search.results[type].size; + const value = getState().search.value; + const offset = getState().search.results[type].size; + const accountId = getState().search.accountId; dispatch(expandSearchRequest(type)); + const params: Record = { + q: value, + type, + offset, + }; + + if (accountId) params.account_id = accountId; + api(getState).get('/api/v2/search', { - params: { - q: value, - type, - offset, - }, + params, }).then(({ data }) => { if (data.accounts) { dispatch(importFetchedAccounts(data.accounts)); diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 902b99f70..1df112dae 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -248,6 +248,9 @@ const expandListTimeline = (id: string, { maxId }: Record = {}, don const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); +const expandGroupFeaturedTimeline = (id: string) => + expandTimeline(`group:${id}:pinned`, `/api/v1/timelines/group/${id}`, { pinned: true }); + const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done); @@ -353,6 +356,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupFeaturedTimeline, expandGroupTimelineFromTag, expandGroupMediaTimeline, expandHashtagTimeline, diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts index 6e41975e5..a9cb2b369 100644 --- a/app/soapbox/api/hooks/groups/useGroupLookup.ts +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -12,7 +12,7 @@ function useGroupLookup(slug: string) { Entities.GROUPS, (group) => group.slug === slug, () => api.get(`/api/v1/groups/lookup?name=${slug}`), - { schema: groupSchema }, + { schema: groupSchema, enabled: !!slug }, ); const { entity: relationship } = useGroupRelationship(group?.id); diff --git a/app/soapbox/components/attachment-thumbs.tsx b/app/soapbox/components/attachment-thumbs.tsx index 3ac1dbf5f..25b4bec00 100644 --- a/app/soapbox/components/attachment-thumbs.tsx +++ b/app/soapbox/components/attachment-thumbs.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { useDispatch } from 'react-redux'; import { openModal } from 'soapbox/actions/modals'; import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery } from 'soapbox/features/ui/util/async-components'; +import { useAppDispatch } from 'soapbox/hooks'; import type { List as ImmutableList } from 'immutable'; import type { Attachment } from 'soapbox/types/entities'; @@ -16,7 +16,7 @@ interface IAttachmentThumbs { const AttachmentThumbs = (props: IAttachmentThumbs) => { const { media, onClick, sensitive } = props; - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const renderLoading = () =>
; const onOpenMedia = (media: ImmutableList, index: number) => dispatch(openModal('MEDIA', { media, index })); diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx index 6ee5a3aee..0b27823d7 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx @@ -86,7 +86,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { title={item.text} className={ clsx({ - 'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true, + 'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 focus:outline-none cursor-pointer': true, 'text-danger-600 dark:text-danger-400': item.destructive, }) } diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx index a5714ff68..48fff7398 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx @@ -1,15 +1,12 @@ -import { offset, Placement, useFloating, flip, arrow } from '@floating-ui/react'; +import { offset, Placement, useFloating, flip, arrow, shift } from '@floating-ui/react'; import clsx from 'clsx'; import { supportsPassiveEvents } from 'detect-passive-events'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { - closeDropdownMenu as closeDropdownMenuRedux, - openDropdownMenu, -} from 'soapbox/actions/dropdown-menu'; +import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu'; import { closeModal, openModal } from 'soapbox/actions/modals'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch } from 'soapbox/hooks'; import { isUserTouching } from 'soapbox/is-mobile'; import { IconButton, Portal } from '../ui'; @@ -53,10 +50,8 @@ const DropdownMenu = (props: IDropdownMenu) => { const history = useHistory(); const [isOpen, setIsOpen] = useState(false); - const isOpenRedux = useAppSelector(state => state.dropdown_menu.isOpen); const arrowRef = useRef(null); - const activeElement = useRef(null); const isOnMobile = isUserTouching(); @@ -65,6 +60,9 @@ const DropdownMenu = (props: IDropdownMenu) => { middleware: [ offset(12), flip(), + shift({ + padding: 8, + }), arrow({ element: arrowRef, }), @@ -113,10 +111,7 @@ const DropdownMenu = (props: IDropdownMenu) => { }; const handleClose = () => { - if (activeElement.current && activeElement.current === refs.reference.current) { - (activeElement.current as any).focus(); - activeElement.current = null; - } + (refs.reference.current as HTMLButtonElement)?.focus(); if (isOnMobile) { dispatch(closeModal('ACTIONS')); @@ -131,24 +126,13 @@ const DropdownMenu = (props: IDropdownMenu) => { }; const closeDropdownMenu = () => { - if (isOpenRedux) { - dispatch(closeDropdownMenuRedux()); - } - }; + dispatch((dispatch, getState) => { + const isOpenRedux = getState().dropdown_menu.isOpen; - const handleMouseDown: React.EventHandler = () => { - if (!isOpen) { - activeElement.current = document.activeElement; - } - }; - - const handleButtonKeyDown: React.EventHandler = (event) => { - switch (event.key) { - case ' ': - case 'Enter': - handleMouseDown(event); - break; - } + if (isOpenRedux) { + dispatch(closeDropdownMenuRedux()); + } + }); }; const handleKeyPress: React.EventHandler> = (event) => { @@ -260,16 +244,22 @@ const DropdownMenu = (props: IDropdownMenu) => { }, []); useEffect(() => { - document.addEventListener('click', handleDocumentClick, false); - document.addEventListener('keydown', handleKeyDown, false); - document.addEventListener('touchend', handleDocumentClick, listenerOptions); + if (isOpen) { + if (refs.floating.current) { + (refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus(); + } - return () => { - document.removeEventListener('click', handleDocumentClick); - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('touchend', handleDocumentClick); - }; - }, [refs.floating.current]); + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('keydown', handleKeyDown, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('touchend', handleDocumentClick); + }; + } + }, [isOpen, refs.floating.current]); if (items.length === 0) { return null; @@ -281,8 +271,6 @@ const DropdownMenu = (props: IDropdownMenu) => { React.cloneElement(children, { disabled, onClick: handleClick, - onMouseDown: handleMouseDown, - onKeyDown: handleButtonKeyDown, onKeyPress: handleKeyPress, ref: refs.setReference, }) @@ -296,8 +284,6 @@ const DropdownMenu = (props: IDropdownMenu) => { title={title} src={src} onClick={handleClick} - onMouseDown={handleMouseDown} - onKeyDown={handleButtonKeyDown} onKeyPress={handleKeyPress} ref={refs.setReference} /> @@ -343,4 +329,4 @@ const DropdownMenu = (props: IDropdownMenu) => { ); }; -export default DropdownMenu; \ No newline at end of file +export default DropdownMenu; diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index b56e0e6a7..dad4c972a 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -43,6 +43,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec const isSelect = child.type === SelectDropdown || child.type === Select; return React.cloneElement(child, { + // @ts-ignore id: domId, className: clsx({ 'w-auto': isSelect, diff --git a/app/soapbox/components/markup.css b/app/soapbox/components/markup.css index f451e3ba1..48e292bcc 100644 --- a/app/soapbox/components/markup.css +++ b/app/soapbox/components/markup.css @@ -85,3 +85,21 @@ body.underline-links [data-markup] a { [data-markup] .status-link { @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue; } + +[data-markup] .invisible { + font-size: 0 !important; + line-height: 0 !important; + display: inline-block; + width: 0; + height: 0; + position: absolute; +} + +[data-markup] .invisible img, +[data-markup] .invisible svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; +} diff --git a/app/soapbox/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index d7f83ea91..7903cc676 100644 --- a/app/soapbox/components/modal-root.tsx +++ b/app/soapbox/components/modal-root.tsx @@ -252,6 +252,7 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) className={clsx({ 'my-2 mx-auto relative pointer-events-none flex items-center min-h-[calc(100%-3.5rem)]': true, 'p-4 md:p-0': type !== 'MEDIA', + '!my-0': type === 'MEDIA', })} > {children} diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 13e1a4a3c..3e6cae67a 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -7,7 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts'; import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; -import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; +import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; @@ -32,78 +32,83 @@ import type { Menu } from 'soapbox/components/dropdown-menu'; import type { Account, Group, Status } from 'soapbox/types/entities'; const messages = defineMessages({ - delete: { id: 'status.delete', defaultMessage: 'Delete' }, - redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, - edit: { id: 'status.edit', defaultMessage: 'Edit' }, - direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, - chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, - mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, - reply: { id: 'status.reply', defaultMessage: 'Reply' }, - share: { id: 'status.share', defaultMessage: 'Share' }, - more: { id: 'status.more', defaultMessage: 'More' }, - replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, - reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Like' }, - disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, - open: { id: 'status.open', defaultMessage: 'Expand this post' }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, - report: { id: 'status.report', defaultMessage: 'Report @{name}' }, - muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, - unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, - unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, - embed: { id: 'status.embed', defaultMessage: 'Embed' }, adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, + chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, - group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, - external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, - markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, - markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, - reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, - reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, - reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, - reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, - reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, - reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, - quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + delete: { id: 'status.delete', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, - redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, - replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, + deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, + external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, + favourite: { id: 'status.favourite', defaultMessage: 'Like' }, groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' }, - deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, + group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, + group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, + markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, + markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + more: { id: 'status.more', defaultMessage: 'More' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + open: { id: 'status.open', defaultMessage: 'Expand this post' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' }, + pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned to Group!' }, + unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, + quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, + reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, + reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, + reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, + reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, + reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, + replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, }); interface IStatusActionBar { status: Status withLabels?: boolean expandable?: boolean - space?: 'expand' | 'compact' + space?: 'sm' | 'md' | 'lg' + statusActionButtonTheme?: 'default' | 'inverse' } const StatusActionBar: React.FC = ({ status, withLabels = false, expandable = true, - space = 'compact', + space = 'sm', + statusActionButtonTheme = 'default', }) => { const intl = useIntl(); const history = useHistory(); @@ -230,6 +235,18 @@ const StatusActionBar: React.FC = ({ dispatch(togglePin(status)); }; + const handleGroupPinClick: React.EventHandler = () => { + const group = status.group as Group; + + if (status.pinned) { + dispatch(unpinFromGroup(status, group)); + } else { + dispatch(pinToGroup(status, group)) + .then(() => toast.success(intl.formatMessage(messages.pinToGroupSuccess))) + .catch(() => null); + } + }; + const handleMentionClick: React.EventHandler = (e) => { dispatch(mentionCompose(status.account as Account)); }; @@ -356,6 +373,19 @@ const StatusActionBar: React.FC = ({ return menu; } + const isGroupStatus = typeof status.group === 'object'; + if (isGroupStatus && !!status.group) { + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + + if (isGroupOwner) { + menu.push({ + text: intl.formatMessage(status.pinned ? messages.unpinFromGroup : messages.pinToGroup), + action: handleGroupPinClick, + icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), + }); + } + } + if (features.bookmarks) { menu.push({ text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), @@ -458,18 +488,23 @@ const StatusActionBar: React.FC = ({ }); } - if (status.group && - groupRelationship?.role && - [GroupRoles.OWNER].includes(groupRelationship.role) && - !ownAccount - ) { - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.groupModDelete), - action: handleDeleteFromGroup, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); + if (isGroupStatus && !!status.group) { + const group = status.group as Group; + const account = status.account as Account; + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + const isGroupAdmin = groupRelationship?.role === GroupRoles.ADMIN; + const isStatusFromOwner = group.owner.id === account.id; + const canDeleteStatus = !ownAccount && (isGroupOwner || (isGroupAdmin && !isStatusFromOwner)); + + if (canDeleteStatus) { + menu.push(null); + menu.push({ + text: intl.formatMessage(messages.groupModDelete), + action: handleDeleteFromGroup, + icon: require('@tabler/icons/trash.svg'), + destructive: true, + }); + } } if (isStaff) { @@ -572,6 +607,7 @@ const StatusActionBar: React.FC = ({ onClick={handleReblogClick} count={reblogCount} text={withLabels ? intl.formatMessage(messages.reblog) : undefined} + theme={statusActionButtonTheme} /> ); @@ -583,13 +619,22 @@ const StatusActionBar: React.FC = ({ const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); + const spacing: { + [key: string]: React.ComponentProps['space'] + } = { + 'sm': 2, + 'md': 8, + 'lg': 0, // using justifyContent instead on the HStack + }; + return ( e.stopPropagation()} + alignItems='center' > = ({ count={replyCount} text={withLabels ? intl.formatMessage(messages.reply) : undefined} disabled={replyDisabled} + theme={statusActionButtonTheme} /> @@ -628,6 +674,7 @@ const StatusActionBar: React.FC = ({ count={emojiReactCount} emoji={meEmojiReact} text={withLabels ? meEmojiTitle : undefined} + theme={statusActionButtonTheme} /> ) : ( @@ -640,6 +687,7 @@ const StatusActionBar: React.FC = ({ active={Boolean(meEmojiName)} count={favouriteCount} text={withLabels ? meEmojiTitle : undefined} + theme={statusActionButtonTheme} /> )} @@ -653,6 +701,7 @@ const StatusActionBar: React.FC = ({ active={status.disliked} count={status.dislikes_count} text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined} + theme={statusActionButtonTheme} /> )} @@ -661,6 +710,7 @@ const StatusActionBar: React.FC = ({ title={intl.formatMessage(messages.share)} icon={require('@tabler/icons/upload.svg')} onClick={handleShareClick} + theme={statusActionButtonTheme} /> )} @@ -668,6 +718,7 @@ const StatusActionBar: React.FC = ({ diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 47b3c11b8..10f952065 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -35,10 +35,11 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes text?: React.ReactNode + theme?: 'default' | 'inverse' } const StatusActionButton = React.forwardRef((props, ref): JSX.Element => { - const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, ...filteredProps } = props; + const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, theme = 'default', ...filteredProps } = props; const renderIcon = () => { if (emoji) { @@ -82,10 +83,10 @@ const StatusActionButton = React.forwardRef = ({ const node = useRef(null); - const { greentext } = useSoapboxConfig(); - const onMentionClick = (mention: Mention, e: MouseEvent) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -134,13 +130,7 @@ const StatusContent: React.FC = ({ }); const parsedHtml = useMemo((): string => { - const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml; - - if (greentext) { - return addGreentext(html); - } else { - return html; - } + return translatable && status.translation ? status.translation.get('content')! : status.contentHtml; }, [status.contentHtml, status.translation]); if (status.content.length === 0) { diff --git a/app/soapbox/components/thumb-navigation.tsx b/app/soapbox/components/thumb-navigation.tsx index 6abaa084e..013ecece7 100644 --- a/app/soapbox/components/thumb-navigation.tsx +++ b/app/soapbox/components/thumb-navigation.tsx @@ -3,15 +3,17 @@ import { FormattedMessage } from 'react-intl'; import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link'; import { useStatContext } from 'soapbox/contexts/stat-context'; -import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { useAppSelector, useFeatures, useGroupsPath, useOwnAccount } from 'soapbox/hooks'; const ThumbNavigation: React.FC = (): JSX.Element => { const account = useOwnAccount(); + const features = useFeatures(); + const groupsPath = useGroupsPath(); + const { unreadChatsCount } = useStatContext(); const notificationCount = useAppSelector((state) => state.notifications.unread); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); - const features = useFeatures(); /** Conditionally render the supported messages link */ const renderMessagesLink = (): React.ReactNode => { @@ -51,6 +53,15 @@ const ThumbNavigation: React.FC = (): JSX.Element => { exact /> + {features.groups && ( + } + to={groupsPath} + exact + /> + )} + } diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index b92fb7e70..2c1c0187e 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -6,22 +6,22 @@ import { Text } from 'soapbox/components/ui'; interface ITombstone { id: string - onMoveUp: (statusId: string) => void - onMoveDown: (statusId: string) => void + onMoveUp?: (statusId: string) => void + onMoveDown?: (statusId: string) => void } /** Represents a deleted item. */ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => { const handlers = { - moveUp: () => onMoveUp(id), - moveDown: () => onMoveDown(id), + moveUp: () => onMoveUp?.(id), + moveDown: () => onMoveDown?.(id), }; return (
= (props) => { if (React.isValidElement(inputChildren[0])) { firstChild = React.cloneElement( inputChildren[0], + // @ts-ignore { id: formFieldId }, ); } + + // @ts-ignore const isCheckboxFormGroup = firstChild?.type === Checkbox; if (isCheckboxFormGroup) { diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index fcd35d16c..1efd956c6 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -18,6 +18,7 @@ const alignItemsOptions = { }; const spaces = { + 0: 'space-x-0', [0.5]: 'space-x-0.5', 1: 'space-x-1', 1.5: 'space-x-1.5', diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 1ece137df..3967538d8 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -12,7 +12,7 @@ interface IIconButton extends React.ButtonHTMLAttributes { /** Text to display next ot the button. */ text?: string /** Predefined styles to display for the button. */ - theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' + theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' | 'dark' /** Override the data-testid */ 'data-testid'?: string } @@ -29,6 +29,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef 'bg-white dark:bg-transparent': theme === 'seamless', 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined', 'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary', + 'bg-gray-900 text-white': theme === 'dark', 'opacity-50': filteredProps.disabled, }, className)} {...filteredProps} diff --git a/app/soapbox/components/ui/menu/menu.css b/app/soapbox/components/ui/menu/menu.css index 96f510304..123f0942a 100644 --- a/app/soapbox/components/ui/menu/menu.css +++ b/app/soapbox/components/ui/menu/menu.css @@ -1,5 +1,5 @@ [data-reach-menu-popover] { - @apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 focus:outline-none z-[1003]; + @apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-gray-800 focus:outline-none z-[1003]; } [data-reach-menu-button] { diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index dceaf9214..edc89d1d5 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -16,6 +16,7 @@ const spaces = { }; const justifyContentOptions = { + between: 'justify-between', center: 'justify-center', end: 'justify-end', }; diff --git a/app/soapbox/entity-store/hooks/useEntityLookup.ts b/app/soapbox/entity-store/hooks/useEntityLookup.ts index a49a659a4..73b2ef938 100644 --- a/app/soapbox/entity-store/hooks/useEntityLookup.ts +++ b/app/soapbox/entity-store/hooks/useEntityLookup.ts @@ -25,6 +25,7 @@ function useEntityLookup( const [isFetching, setPromise] = useLoading(true); const entity = useAppSelector(state => findEntity(state, entityType, lookupFn)); + const isEnabled = opts.enabled ?? true; const isLoading = isFetching && !entity; const fetchEntity = async () => { @@ -38,10 +39,12 @@ function useEntityLookup( }; useEffect(() => { + if (!isEnabled) return; + if (!entity || opts.refetch) { fetchEntity(); } - }, []); + }, [isEnabled]); return { entity, diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 6af45db24..4636795bc 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -85,7 +85,7 @@ const Ad: React.FC = ({ ad }) => { diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index 700d108e3..08a57b046 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -31,6 +31,7 @@ import { countableText } from '../util/counter'; import PollButton from './poll-button'; import PollForm from './polls/poll-form'; import PrivacyDropdown from './privacy-dropdown'; +import ReplyGroupIndicator from './reply-group-indicator'; import ReplyMentions from './reply-mentions'; import ScheduleButton from './schedule-button'; import SpoilerButton from './spoiler-button'; @@ -264,6 +265,8 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab + {!shouldCondense && !event && !group && groupId && } + {!shouldCondense && !event && !group && } {!shouldCondense && !event && !group && } diff --git a/app/soapbox/features/compose/components/reply-group-indicator.tsx b/app/soapbox/features/compose/components/reply-group-indicator.tsx new file mode 100644 index 000000000..bc808dc5a --- /dev/null +++ b/app/soapbox/features/compose/components/reply-group-indicator.tsx @@ -0,0 +1,42 @@ +import React, { useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Link from 'soapbox/components/link'; +import { Text } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; +import { Group } from 'soapbox/schemas'; +import { makeGetStatus } from 'soapbox/selectors'; + +interface IReplyGroupIndicator { + composeId: string +} + +const ReplyGroupIndicator = (props: IReplyGroupIndicator) => { + const { composeId } = props; + + const getStatus = useCallback(makeGetStatus(), []); + + const status = useAppSelector((state) => getStatus(state, { id: state.compose.get(composeId)?.in_reply_to! })); + const group = status?.group as Group; + + if (!group) { + return null; + } + + return ( + + , + }} + /> + + ); +}; + +export default ReplyGroupIndicator; \ No newline at end of file diff --git a/app/soapbox/features/compose/components/reply-mentions.tsx b/app/soapbox/features/compose/components/reply-mentions.tsx index 511950c8e..333b76504 100644 --- a/app/soapbox/features/compose/components/reply-mentions.tsx +++ b/app/soapbox/features/compose/components/reply-mentions.tsx @@ -1,9 +1,8 @@ import React, { useCallback } from 'react'; import { FormattedList, FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { openModal } from 'soapbox/actions/modals'; -import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose'; import { makeGetStatus } from 'soapbox/selectors'; import { isPubkey } from 'soapbox/utils/nostr'; @@ -15,7 +14,7 @@ interface IReplyMentions { } const ReplyMentions: React.FC = ({ composeId }) => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const features = useFeatures(); const compose = useCompose(composeId); diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index 3a3bdcd6b..87c0fa358 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -138,7 +138,7 @@ const Search = (props: ISearch) => { useEffect(() => { return () => { const newPath = history.location.pathname; - const shouldPersistSearch = !!newPath.match(/@.+\/posts\/\d+/g) + const shouldPersistSearch = !!newPath.match(/@.+\/posts\/[a-zA-Z0-9]+/g) || !!newPath.match(/\/tags\/.+/g); if (!shouldPersistSearch) { diff --git a/app/soapbox/features/crypto-donate/components/crypto-address.tsx b/app/soapbox/features/crypto-donate/components/crypto-address.tsx index 65c4819a2..9352ed029 100644 --- a/app/soapbox/features/crypto-donate/components/crypto-address.tsx +++ b/app/soapbox/features/crypto-donate/components/crypto-address.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { useDispatch } from 'react-redux'; import { openModal } from 'soapbox/actions/modals'; import CopyableInput from 'soapbox/components/copyable-input'; import { Text, Icon, Stack, HStack } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; import { getExplorerUrl } from '../utils/block-explorer'; import { getTitle } from '../utils/coin-db'; @@ -19,7 +19,7 @@ export interface ICryptoAddress { const CryptoAddress: React.FC = (props): JSX.Element => { const { address, ticker, note } = props; - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const handleModalClick = (e: React.MouseEvent): void => { dispatch(openModal('CRYPTO_DONATE', props)); diff --git a/app/soapbox/features/emoji/__tests__/emoji-index.test.ts b/app/soapbox/features/emoji/__tests__/emoji-index.test.ts index 59e2e9bef..925e2627a 100644 --- a/app/soapbox/features/emoji/__tests__/emoji-index.test.ts +++ b/app/soapbox/features/emoji/__tests__/emoji-index.test.ts @@ -19,22 +19,12 @@ describe('emoji_index', () => { it('orders search results correctly', () => { const expected = [ - { - id: 'pineapple', - unified: '1f34d', - native: '🍍', - }, - { - id: 'apple', - unified: '1f34e', - native: '🍎', - }, - { - id: 'green_apple', - unified: '1f34f', - native: '🍏', - }, + { id: 'apple', unified: '1f34e', native: '🍎' }, + { id: 'pineapple', unified: '1f34d', native: '🍍' }, + { id: 'green_apple', unified: '1f34f', native: '🍏' }, + { id: 'iphone', unified: '1f4f1', native: '📱' }, ]; + expect(search('apple').map(trimEmojis)).toEqual(expected); }); diff --git a/app/soapbox/features/emoji/search.ts b/app/soapbox/features/emoji/search.ts index dbcb29756..363af2106 100644 --- a/app/soapbox/features/emoji/search.ts +++ b/app/soapbox/features/emoji/search.ts @@ -11,8 +11,9 @@ const index = new Index({ context: true, }); -for (const [key, emoji] of Object.entries(data.emojis)) { - index.add('n' + key, emoji.name); +const sortedEmojis = Object.entries(data.emojis).sort((a, b) => a[0].localeCompare(b[0])); +for (const [key, emoji] of sortedEmojis) { + index.add('n' + key, `${emoji.id} ${emoji.name} ${emoji.keywords.join(' ')}`); } export interface searchOptions { diff --git a/app/soapbox/features/event/event-discussion.tsx b/app/soapbox/features/event/event-discussion.tsx index 3c96c73b8..77475e222 100644 --- a/app/soapbox/features/event/event-discussion.tsx +++ b/app/soapbox/features/event/event-discussion.tsx @@ -15,7 +15,7 @@ import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; import ComposeForm from '../compose/components/compose-form'; -import { getDescendantsIds } from '../status'; +import { getDescendantsIds } from '../status/components/thread'; import ThreadStatus from '../status/components/thread-status'; import type { VirtuosoHandle } from 'react-virtuoso'; diff --git a/app/soapbox/features/group/components/group-member-count.tsx b/app/soapbox/features/group/components/group-member-count.tsx index 6dc936181..d6e0223f4 100644 --- a/app/soapbox/features/group/components/group-member-count.tsx +++ b/app/soapbox/features/group/components/group-member-count.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; import { Text } from 'soapbox/components/ui'; import { Group } from 'soapbox/types/entities'; @@ -11,17 +12,19 @@ interface IGroupMemberCount { const GroupMemberCount = ({ group }: IGroupMemberCount) => { return ( - - {shortNumberFormat(group.members_count)} - {' '} - - + + + {shortNumberFormat(group.members_count)} + {' '} + + + ); }; diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index f9b18735d..a2c2c951c 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -22,7 +22,7 @@ import type { Group, GroupMember } from 'soapbox/types/entities'; const messages = defineMessages({ adminLimitTitle: { id: 'group.member.admin.limit.title', defaultMessage: 'Admin limit reached' }, - adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count} admins for the group at this time.' }, + adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count, plural, one {admin} other {admins}} for the group at this time.' }, blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' }, blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' }, blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' }, @@ -195,8 +195,8 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { data-testid='role-badge' className={ clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', { - 'bg-primary-200 text-primary-500': isMemberOwner, - 'bg-gray-200 text-gray-900': isMemberAdmin, + 'bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200': isMemberOwner, + 'bg-gray-200 text-gray-900 dark:bg-gray-800 dark:text-gray-100': isMemberAdmin, }) } > @@ -210,4 +210,4 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { ); }; -export default GroupMemberListItem; \ No newline at end of file +export default GroupMemberListItem; diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx index a920a862c..3c736cd78 100644 --- a/app/soapbox/features/group/group-timeline.tsx +++ b/app/soapbox/features/group/group-timeline.tsx @@ -5,11 +5,12 @@ import { Link } from 'react-router-dom'; import { groupCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; import { connectGroupStream } from 'soapbox/actions/streaming'; -import { expandGroupTimeline } from 'soapbox/actions/timelines'; +import { expandGroupFeaturedTimeline, expandGroupTimeline } from 'soapbox/actions/timelines'; import { useGroup } from 'soapbox/api/hooks'; import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui'; import ComposeForm from 'soapbox/features/compose/components/compose-form'; import { useAppDispatch, useAppSelector, useDraggedFiles, useOwnAccount } from 'soapbox/hooks'; +import { makeGetStatusIds } from 'soapbox/selectors'; import Timeline from '../ui/components/timeline'; @@ -19,6 +20,8 @@ interface IGroupTimeline { params: RouteParams } +const getStatusIds = makeGetStatusIds(); + const GroupTimeline: React.FC = (props) => { const intl = useIntl(); const account = useOwnAccount(); @@ -32,6 +35,7 @@ const GroupTimeline: React.FC = (props) => { const composeId = `group:${groupId}`; const canComposeGroupStatus = !!account && group?.relationship?.member; const groupTimelineVisible = useAppSelector((state) => !!state.compose.get(composeId)?.group_timeline_visible); + const featuredStatusIds = useAppSelector((state) => getStatusIds(state, { type: `group:${group?.id}:pinned` })); const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => { dispatch(uploadCompose(composeId, files, intl)); @@ -47,6 +51,7 @@ const GroupTimeline: React.FC = (props) => { useEffect(() => { dispatch(expandGroupTimeline(groupId)); + dispatch(expandGroupFeaturedTimeline(groupId)); dispatch(groupCompose(composeId, groupId)); const disconnect = dispatch(connectGroupStream(groupId)); @@ -123,6 +128,7 @@ const GroupTimeline: React.FC = (props) => { emptyMessageCard={false} divideType='border' showGroup={false} + featuredStatusIds={featuredStatusIds} /> ); diff --git a/app/soapbox/features/groups/components/discover/group-list-item.tsx b/app/soapbox/features/groups/components/discover/group-list-item.tsx index fc2aedcf5..6331d9d05 100644 --- a/app/soapbox/features/groups/components/discover/group-list-item.tsx +++ b/app/soapbox/features/groups/components/discover/group-list-item.tsx @@ -22,17 +22,18 @@ const GroupListItem = (props: IGroup) => { justifyContent='between' data-testid='group-list-item' > - + - + diff --git a/app/soapbox/features/groups/suggested.tsx b/app/soapbox/features/groups/suggested.tsx index 89833a9a8..8a3e570e0 100644 --- a/app/soapbox/features/groups/suggested.tsx +++ b/app/soapbox/features/groups/suggested.tsx @@ -13,7 +13,7 @@ import LayoutButtons, { GroupLayout } from './components/discover/layout-buttons import type { Group } from 'soapbox/schemas'; const messages = defineMessages({ - label: { id: 'groups.popular.label', defaultMessage: 'Suggested Groups' }, + label: { id: 'groups.suggested.label', defaultMessage: 'Suggested Groups' }, }); const GridList: Components['List'] = React.forwardRef((props, ref) => { diff --git a/app/soapbox/features/placeholder/components/placeholder-group-search.tsx b/app/soapbox/features/placeholder/components/placeholder-group-search.tsx index b2e2dc6f8..3b3bd3870 100644 --- a/app/soapbox/features/placeholder/components/placeholder-group-search.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-group-search.tsx @@ -4,7 +4,7 @@ import { HStack, Stack, Text } from 'soapbox/components/ui'; import { generateText, randomIntFromInterval } from '../utils'; -export default () => { +export default ({ withJoinAction = true }: { withJoinAction?: boolean }) => { const groupNameLength = randomIntFromInterval(12, 20); return ( @@ -13,7 +13,7 @@ export default () => { justifyContent='between' className='animate-pulse' > - + {/* Group Avatar */}
@@ -37,7 +37,9 @@ export default () => { {/* Join Group Button */} -
+ {withJoinAction && ( +
+ )} ); }; diff --git a/app/soapbox/features/security/mfa-form.tsx b/app/soapbox/features/security/mfa-form.tsx index 1fd47445f..346617da8 100644 --- a/app/soapbox/features/security/mfa-form.tsx +++ b/app/soapbox/features/security/mfa-form.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { fetchMfa } from 'soapbox/actions/mfa'; -import { Card, CardBody, CardHeader, CardTitle, Column, Stack } from 'soapbox/components/ui'; +import { Column, Stack } from 'soapbox/components/ui'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import DisableOtpForm from './mfa/disable-otp-form'; @@ -37,23 +37,15 @@ const MfaForm: React.FC = () => { const mfa = useAppSelector((state) => state.security.get('mfa')); return ( - - - - - - - - {mfa.getIn(['settings', 'totp']) ? ( - - ) : ( - - - {displayOtpForm && } - - )} - - + + {mfa.getIn(['settings', 'totp']) ? ( + + ) : ( + + + {displayOtpForm && } + + )} ); }; diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 15f2e86e8..46b2a338e 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -15,15 +15,12 @@ import { getActualStatus } from 'soapbox/utils/status'; import StatusInteractionBar from './status-interaction-bar'; -import type { List as ImmutableList } from 'immutable'; -import type { Attachment as AttachmentEntity, Group, Status as StatusEntity } from 'soapbox/types/entities'; +import type { Group, Status as StatusEntity } from 'soapbox/types/entities'; interface IDetailedStatus { status: StatusEntity - onOpenMedia: (media: ImmutableList, index: number) => void - onOpenVideo: (media: ImmutableList, start: number) => void - onToggleHidden: (status: StatusEntity) => void - showMedia: boolean + showMedia?: boolean + withMedia?: boolean onOpenCompareHistoryModal: (status: StatusEntity) => void onToggleMediaVisibility: () => void } @@ -33,6 +30,7 @@ const DetailedStatus: React.FC = ({ onOpenCompareHistoryModal, onToggleMediaVisibility, showMedia, + withMedia = true, }) => { const intl = useIntl(); @@ -155,7 +153,7 @@ const DetailedStatus: React.FC = ({ - {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( + {(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.size > 0)) && ( = ({ status }): JSX. const me = useAppSelector(({ me }) => me); const { allowedEmoji } = useSoapboxConfig(); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const features = useFeatures(); const { account } = status; diff --git a/app/soapbox/features/status/components/thread.tsx b/app/soapbox/features/status/components/thread.tsx new file mode 100644 index 000000000..aa563e839 --- /dev/null +++ b/app/soapbox/features/status/components/thread.tsx @@ -0,0 +1,468 @@ +import { createSelector } from '@reduxjs/toolkit'; +import clsx from 'clsx'; +import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import React, { useEffect, useRef, useState } from 'react'; +import { HotKeys } from 'react-hotkeys'; +import { useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import { type VirtuosoHandle } from 'react-virtuoso'; + +import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; +import { favourite, reblog, unfavourite, unreblog } from 'soapbox/actions/interactions'; +import { openModal } from 'soapbox/actions/modals'; +import { getSettings } from 'soapbox/actions/settings'; +import { hideStatus, revealStatus } from 'soapbox/actions/statuses'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import StatusActionBar from 'soapbox/components/status-action-bar'; +import Tombstone from 'soapbox/components/tombstone'; +import { Stack } from 'soapbox/components/ui'; +import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status'; +import PendingStatus from 'soapbox/features/ui/components/pending-status'; +import { useAppDispatch, useAppSelector, useOwnAccount, useSettings } from 'soapbox/hooks'; +import { RootState } from 'soapbox/store'; +import { type Account, type Status } from 'soapbox/types/entities'; +import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; + +import DetailedStatus from './detailed-status'; +import ThreadLoginCta from './thread-login-cta'; +import ThreadStatus from './thread-status'; + +type DisplayMedia = 'default' | 'hide_all' | 'show_all'; + +const getAncestorsIds = createSelector([ + (_: RootState, statusId: string | undefined) => statusId, + (state: RootState) => state.contexts.inReplyTos, +], (statusId, inReplyTos) => { + let ancestorsIds = ImmutableOrderedSet(); + let id: string | undefined = statusId; + + while (id && !ancestorsIds.includes(id)) { + ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); + id = inReplyTos.get(id); + } + + return ancestorsIds; +}); + +export const getDescendantsIds = createSelector([ + (_: RootState, statusId: string) => statusId, + (state: RootState) => state.contexts.replies, +], (statusId, contextReplies) => { + let descendantsIds = ImmutableOrderedSet(); + const ids = [statusId]; + + while (ids.length > 0) { + const id = ids.shift(); + if (!id) break; + + const replies = contextReplies.get(id); + + if (descendantsIds.includes(id)) { + break; + } + + if (statusId !== id) { + descendantsIds = descendantsIds.union([id]); + } + + if (replies) { + replies.reverse().forEach((reply: string) => { + ids.unshift(reply); + }); + } + } + + return descendantsIds; +}); + +interface IThread { + status: Status + withMedia?: boolean + useWindowScroll?: boolean + itemClassName?: string + next: string | undefined + handleLoadMore: () => void +} + +const Thread = (props: IThread) => { + const { + handleLoadMore, + itemClassName, + next, + status, + useWindowScroll = true, + withMedia = true, + } = props; + + const dispatch = useAppDispatch(); + const history = useHistory(); + const intl = useIntl(); + const me = useOwnAccount(); + const settings = useSettings(); + + const displayMedia = settings.get('displayMedia') as DisplayMedia; + const isUnderReview = status?.visibility === 'self'; + + const { ancestorsIds, descendantsIds } = useAppSelector((state) => { + let ancestorsIds = ImmutableOrderedSet(); + let descendantsIds = ImmutableOrderedSet(); + + if (status) { + const statusId = status.id; + ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId)); + descendantsIds = getDescendantsIds(state, statusId); + ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds); + descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds); + } + + return { + status, + ancestorsIds, + descendantsIds, + }; + }); + + const [showMedia, setShowMedia] = useState(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); + + const node = useRef(null); + const statusRef = useRef(null); + const scroller = useRef(null); + + const handleToggleMediaVisibility = () => { + setShowMedia(!showMedia); + }; + + const handleHotkeyReact = () => { + if (statusRef.current) { + const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); + } + }; + + const handleFavouriteClick = (status: Status) => { + if (status.favourited) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + const handleReplyClick = (status: Status) => dispatch(replyCompose(status)); + + const handleModalReblog = (status: Status) => dispatch(reblog(status)); + + const handleReblogClick = (status: Status, e?: React.MouseEvent) => { + dispatch((_, getState) => { + const boostModal = getSettings(getState()).get('boostModal'); + if (status.reblogged) { + dispatch(unreblog(status)); + } else { + if ((e && e.shiftKey) || !boostModal) { + handleModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: handleModalReblog })); + } + } + }); + }; + + const handleMentionClick = (account: Account) => dispatch(mentionCompose(account)); + + const handleHotkeyOpenMedia = (e?: KeyboardEvent) => { + const media = status?.media_attachments; + + e?.preventDefault(); + + if (media && media.size) { + const firstAttachment = media.first()!; + + if (media.size === 1 && firstAttachment.type === 'video') { + dispatch(openModal('VIDEO', { media: firstAttachment, status: status })); + } else { + dispatch(openModal('MEDIA', { media, index: 0, status: status })); + } + } + }; + + const handleToggleHidden = (status: Status) => { + if (status.hidden) { + dispatch(revealStatus(status.id)); + } else { + dispatch(hideStatus(status.id)); + } + }; + + const handleHotkeyMoveUp = () => { + handleMoveUp(status!.id); + }; + + const handleHotkeyMoveDown = () => { + handleMoveDown(status!.id); + }; + + const handleHotkeyReply = (e?: KeyboardEvent) => { + e?.preventDefault(); + handleReplyClick(status!); + }; + + const handleHotkeyFavourite = () => { + handleFavouriteClick(status!); + }; + + const handleHotkeyBoost = () => { + handleReblogClick(status!); + }; + + const handleHotkeyMention = (e?: KeyboardEvent) => { + e?.preventDefault(); + const { account } = status!; + if (!account || typeof account !== 'object') return; + handleMentionClick(account); + }; + + const handleHotkeyOpenProfile = () => { + history.push(`/@${status!.getIn(['account', 'acct'])}`); + }; + + const handleHotkeyToggleHidden = () => { + handleToggleHidden(status!); + }; + + const handleHotkeyToggleSensitive = () => { + handleToggleMediaVisibility(); + }; + + const handleMoveUp = (id: string) => { + if (id === status?.id) { + _selectChild(ancestorsIds.size - 1); + } else { + let index = ImmutableList(ancestorsIds).indexOf(id); + + if (index === -1) { + index = ImmutableList(descendantsIds).indexOf(id); + _selectChild(ancestorsIds.size + index); + } else { + _selectChild(index - 1); + } + } + }; + + const handleMoveDown = (id: string) => { + if (id === status?.id) { + _selectChild(ancestorsIds.size + 1); + } else { + let index = ImmutableList(ancestorsIds).indexOf(id); + + if (index === -1) { + index = ImmutableList(descendantsIds).indexOf(id); + _selectChild(ancestorsIds.size + index + 2); + } else { + _selectChild(index + 1); + } + } + }; + + const _selectChild = (index: number) => { + scroller.current?.scrollIntoView({ + index, + behavior: 'smooth', + done: () => { + const element = document.querySelector(`#thread [data-index="${index}"] .focusable`); + + if (element) { + element.focus(); + } + }, + }); + }; + + const renderTombstone = (id: string) => { + return ( +
+ +
+ ); + }; + + const renderStatus = (id: string) => { + return ( + + ); + }; + + const renderPendingStatus = (id: string) => { + const idempotencyKey = id.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderChildren = (list: ImmutableOrderedSet) => { + return list.map(id => { + if (id.endsWith('-tombstone')) { + return renderTombstone(id); + } else if (id.startsWith('末pending-')) { + return renderPendingStatus(id); + } else { + return renderStatus(id); + } + }); + }; + + // Reset media visibility if status changes. + useEffect(() => { + setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); + }, [status.id]); + + // Scroll focused status into view when thread updates. + useEffect(() => { + scroller.current?.scrollToIndex({ + index: ancestorsIds.size, + offset: -146, + }); + + setImmediate(() => statusRef.current?.querySelector('.detailed-actualStatus')?.focus()); + }, [status.id, ancestorsIds.size]); + + const handleOpenCompareHistoryModal = (status: Status) => { + dispatch(openModal('COMPARE_HISTORY', { + statusId: status.id, + })); + }; + + const hasAncestors = ancestorsIds.size > 0; + const hasDescendants = descendantsIds.size > 0; + + type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; + + const handlers: HotkeyHandlers = { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + reply: handleHotkeyReply, + favourite: handleHotkeyFavourite, + boost: handleHotkeyBoost, + mention: handleHotkeyMention, + openProfile: handleHotkeyOpenProfile, + toggleHidden: handleHotkeyToggleHidden, + toggleSensitive: handleHotkeyToggleSensitive, + openMedia: handleHotkeyOpenMedia, + react: handleHotkeyReact, + }; + + const focusedStatus = ( +
+ +
+ + + + {!isUnderReview ? ( + <> +
+ + + + ) : null} +
+
+ + {hasDescendants && ( +
+ )} +
+ ); + + const children: JSX.Element[] = []; + + if (!useWindowScroll) { + // Add padding to the top of the Thread (for Media Modal) + children.push(
); + } + + if (hasAncestors) { + children.push(...renderChildren(ancestorsIds).toArray()); + } + + children.push(focusedStatus); + + if (hasDescendants) { + children.push(...renderChildren(descendantsIds).toArray()); + } + + return ( + +
+ } + initialTopMostItemIndex={ancestorsIds.size} + useWindowScroll={useWindowScroll} + itemClassName={itemClassName} + className={ + clsx({ + 'h-full': !useWindowScroll, + }) + } + > + {children} + +
+ + {!me && } +
+ ); +}; + +export default Thread; \ No newline at end of file diff --git a/app/soapbox/features/status/containers/quoted-status-container.tsx b/app/soapbox/features/status/containers/quoted-status-container.tsx index fa60f65c4..58d4dbd68 100644 --- a/app/soapbox/features/status/containers/quoted-status-container.tsx +++ b/app/soapbox/features/status/containers/quoted-status-container.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import QuotedStatus from 'soapbox/components/quoted-status'; +import Tombstone from 'soapbox/components/tombstone'; import { useAppSelector } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; @@ -18,6 +19,10 @@ const QuotedStatusContainer: React.FC = ({ statusId }) = return null; } + if (status.tombstone) { + return ; + } + return ( statusId, - (state: RootState) => state.contexts.inReplyTos, -], (statusId, inReplyTos) => { - let ancestorsIds = ImmutableOrderedSet(); - let id: string | undefined = statusId; - - while (id && !ancestorsIds.includes(id)) { - ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); - id = inReplyTos.get(id); - } - - return ancestorsIds; -}); - -export const getDescendantsIds = createSelector([ - (_: RootState, statusId: string) => statusId, - (state: RootState) => state.contexts.replies, -], (statusId, contextReplies) => { - let descendantsIds = ImmutableOrderedSet(); - const ids = [statusId]; - - while (ids.length > 0) { - const id = ids.shift(); - if (!id) break; - - const replies = contextReplies.get(id); - - if (descendantsIds.includes(id)) { - break; - } - - if (statusId !== id) { - descendantsIds = descendantsIds.union([id]); - } - - if (replies) { - replies.reverse().forEach((reply: string) => { - ids.unshift(reply); - }); - } - } - - return descendantsIds; -}); - -type DisplayMedia = 'default' | 'hide_all' | 'show_all'; - type RouteParams = { statusId: string groupId?: string groupSlug?: string }; -interface IThread { +interface IStatusDetails { params: RouteParams - onOpenMedia: (media: ImmutableList, index: number) => void - onOpenVideo: (video: AttachmentEntity, time: number) => void } -const Thread: React.FC = (props) => { - const intl = useIntl(); - const history = useHistory(); +const StatusDetails: React.FC = (props) => { const dispatch = useAppDispatch(); + const intl = useIntl(); - const settings = useSettings(); const getStatus = useCallback(makeGetStatus(), []); + const status = useAppSelector((state) => getStatus(state, { id: props.params.statusId })); - const me = useAppSelector(state => state.me); - const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); - const displayMedia = settings.get('displayMedia') as DisplayMedia; - const isUnderReview = status?.visibility === 'self'; - - const { ancestorsIds, descendantsIds } = useAppSelector(state => { - let ancestorsIds = ImmutableOrderedSet(); - let descendantsIds = ImmutableOrderedSet(); - - if (status) { - const statusId = status.id; - ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId)); - descendantsIds = getDescendantsIds(state, statusId); - ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds); - descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds); - } - - return { - status, - ancestorsIds, - descendantsIds, - }; - }); - - const [showMedia, setShowMedia] = useState(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); const [isLoaded, setIsLoaded] = useState(!!status); const [next, setNext] = useState(); - const node = useRef(null); - const statusRef = useRef(null); - const scroller = useRef(null); - /** Fetch the status (and context) from the API. */ const fetchData = async () => { const { params } = props; @@ -179,241 +66,11 @@ const Thread: React.FC = (props) => { useEffect(() => { fetchData().then(() => { setIsLoaded(true); - }).catch(error => { + }).catch(() => { setIsLoaded(true); }); }, [props.params.statusId]); - const handleToggleMediaVisibility = () => { - setShowMedia(!showMedia); - }; - - const handleHotkeyReact = () => { - if (statusRef.current) { - const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); - firstEmoji?.focus(); - } - }; - - const handleFavouriteClick = (status: StatusEntity) => { - if (status.favourited) { - dispatch(unfavourite(status)); - } else { - dispatch(favourite(status)); - } - }; - - const handleReplyClick = (status: StatusEntity) => { - dispatch(replyCompose(status)); - }; - - const handleModalReblog = (status: StatusEntity) => { - dispatch(reblog(status)); - }; - - const handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => { - dispatch((_, getState) => { - const boostModal = getSettings(getState()).get('boostModal'); - if (status.reblogged) { - dispatch(unreblog(status)); - } else { - if ((e && e.shiftKey) || !boostModal) { - handleModalReblog(status); - } else { - dispatch(openModal('BOOST', { status, onReblog: handleModalReblog })); - } - } - }); - }; - - const handleMentionClick = (account: AccountEntity) => { - dispatch(mentionCompose(account)); - }; - - const handleOpenMedia = (media: ImmutableList, index: number) => { - dispatch(openModal('MEDIA', { media, status, index })); - }; - - const handleOpenVideo = (media: ImmutableList, time: number) => { - dispatch(openModal('VIDEO', { media, time })); - }; - - const handleHotkeyOpenMedia = (e?: KeyboardEvent) => { - const { onOpenMedia, onOpenVideo } = props; - const firstAttachment = status?.media_attachments.get(0); - - e?.preventDefault(); - - if (status && firstAttachment) { - if (firstAttachment.type === 'video') { - onOpenVideo(firstAttachment, 0); - } else { - onOpenMedia(status.media_attachments, 0); - } - } - }; - - const handleToggleHidden = (status: StatusEntity) => { - if (status.hidden) { - dispatch(revealStatus(status.id)); - } else { - dispatch(hideStatus(status.id)); - } - }; - - const handleHotkeyMoveUp = () => { - handleMoveUp(status!.id); - }; - - const handleHotkeyMoveDown = () => { - handleMoveDown(status!.id); - }; - - const handleHotkeyReply = (e?: KeyboardEvent) => { - e?.preventDefault(); - handleReplyClick(status!); - }; - - const handleHotkeyFavourite = () => { - handleFavouriteClick(status!); - }; - - const handleHotkeyBoost = () => { - handleReblogClick(status!); - }; - - const handleHotkeyMention = (e?: KeyboardEvent) => { - e?.preventDefault(); - const { account } = status!; - if (!account || typeof account !== 'object') return; - handleMentionClick(account); - }; - - const handleHotkeyOpenProfile = () => { - history.push(`/@${status!.getIn(['account', 'acct'])}`); - }; - - const handleHotkeyToggleHidden = () => { - handleToggleHidden(status!); - }; - - const handleHotkeyToggleSensitive = () => { - handleToggleMediaVisibility(); - }; - - const handleMoveUp = (id: string) => { - if (id === status?.id) { - _selectChild(ancestorsIds.size - 1); - } else { - let index = ImmutableList(ancestorsIds).indexOf(id); - - if (index === -1) { - index = ImmutableList(descendantsIds).indexOf(id); - _selectChild(ancestorsIds.size + index); - } else { - _selectChild(index - 1); - } - } - }; - - const handleMoveDown = (id: string) => { - if (id === status?.id) { - _selectChild(ancestorsIds.size + 1); - } else { - let index = ImmutableList(ancestorsIds).indexOf(id); - - if (index === -1) { - index = ImmutableList(descendantsIds).indexOf(id); - _selectChild(ancestorsIds.size + index + 2); - } else { - _selectChild(index + 1); - } - } - }; - - const _selectChild = (index: number) => { - scroller.current?.scrollIntoView({ - index, - behavior: 'smooth', - done: () => { - const element = document.querySelector(`#thread [data-index="${index}"] .focusable`); - - if (element) { - element.focus(); - } - }, - }); - }; - - const renderTombstone = (id: string) => { - return ( -
- -
- ); - }; - - const renderStatus = (id: string) => { - return ( - - ); - }; - - const renderPendingStatus = (id: string) => { - const idempotencyKey = id.replace(/^末pending-/, ''); - - return ( - - ); - }; - - const renderChildren = (list: ImmutableOrderedSet) => { - return list.map(id => { - if (id.endsWith('-tombstone')) { - return renderTombstone(id); - } else if (id.startsWith('末pending-')) { - return renderPendingStatus(id); - } else { - return renderStatus(id); - } - }); - }; - - // Reset media visibility if status changes. - useEffect(() => { - setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); - }, [status?.id]); - - // Scroll focused status into view when thread updates. - useEffect(() => { - scroller.current?.scrollToIndex({ - index: ancestorsIds.size, - offset: -146, - }); - - setImmediate(() => statusRef.current?.querySelector('.detailed-actualStatus')?.focus()); - }, [props.params.statusId, status?.id, ancestorsIds.size, isLoaded]); - - const handleRefresh = () => { - return fetchData(); - }; - const handleLoadMore = useCallback(debounce(() => { if (next && status) { dispatch(fetchNext(status.id, next)).then(({ next }) => { @@ -422,15 +79,10 @@ const Thread: React.FC = (props) => { } }, 300, { leading: true }), [next, status]); - const handleOpenCompareHistoryModal = (status: StatusEntity) => { - dispatch(openModal('COMPARE_HISTORY', { - statusId: status.id, - })); + const handleRefresh = () => { + return fetchData(); }; - const hasAncestors = ancestorsIds.size > 0; - const hasDescendants = descendantsIds.size > 0; - if (status?.event) { return ( @@ -449,76 +101,6 @@ const Thread: React.FC = (props) => { ); } - type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; - - const handlers: HotkeyHandlers = { - moveUp: handleHotkeyMoveUp, - moveDown: handleHotkeyMoveDown, - reply: handleHotkeyReply, - favourite: handleHotkeyFavourite, - boost: handleHotkeyBoost, - mention: handleHotkeyMention, - openProfile: handleHotkeyOpenProfile, - toggleHidden: handleHotkeyToggleHidden, - toggleSensitive: handleHotkeyToggleSensitive, - openMedia: handleHotkeyOpenMedia, - react: handleHotkeyReact, - }; - - const focusedStatus = ( -
- -
- - - - {!isUnderReview ? ( - <> -
- - - - ) : null} -
-
- - {hasDescendants && ( -
- )} -
- ); - - const children: JSX.Element[] = []; - - if (hasAncestors) { - children.push(...renderChildren(ancestorsIds).toArray()); - } - - children.push(focusedStatus); - - if (hasDescendants) { - children.push(...renderChildren(descendantsIds).toArray()); - } - if (status.group && typeof status.group === 'object') { if (status.group.slug && !props.params.groupSlug) { return ; @@ -533,25 +115,14 @@ const Thread: React.FC = (props) => { return ( - -
- } - initialTopMostItemIndex={ancestorsIds.size} - > - {children} - -
- - {!me && } -
+
); }; -export default Thread; +export default StatusDetails; diff --git a/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx b/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx index 11d9bdea1..a65522a4b 100644 --- a/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/compose-button.test.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import '@testing-library/jest-dom'; import { MemoryRouter } from 'react-router-dom'; -import { MODAL_OPEN } from 'soapbox/actions/modals'; +import { MODAL_CLOSE, MODAL_OPEN } from 'soapbox/actions/modals'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import ComposeButton from '../compose-button'; @@ -35,6 +35,7 @@ describe('', () => { expect(store.getActions().length).toEqual(0); fireEvent.click(screen.getByRole('button')); - expect(store.getActions()[0].type).toEqual(MODAL_OPEN); + expect(store.getActions()[0].type).toEqual(MODAL_CLOSE); + expect(store.getActions()[1].type).toEqual(MODAL_OPEN); }); }); diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx index d87126c74..a05d1bbf1 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx @@ -1,6 +1,7 @@ import { AxiosError } from 'axios'; import React, { useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { z } from 'zod'; import { useCreateGroup, useGroupValidation, type CreateGroupParams } from 'soapbox/api/hooks'; import { Modal, Stack } from 'soapbox/components/ui'; @@ -71,9 +72,9 @@ const CreateGroupModal: React.FC = ({ onClose }) => { }, onError(error) { if (error instanceof AxiosError) { - const msg = error.response?.data.error; - if (typeof msg === 'string') { - toast.error(msg); + const msg = z.object({ error: z.string() }).safeParse(error.response?.data); + if (msg.success) { + toast.error(msg.data.error); } } }, diff --git a/app/soapbox/features/ui/components/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index 530b1e6cc..875506175 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -1,15 +1,22 @@ import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { useHistory } from 'react-router-dom'; import ReactSwipeableViews from 'react-swipeable-views'; +import { fetchNext, fetchStatusWithContext } from 'soapbox/actions/statuses'; import ExtendedVideoPlayer from 'soapbox/components/extended-video-player'; -import Icon from 'soapbox/components/icon'; -import IconButton from 'soapbox/components/icon-button'; +import MissingIndicator from 'soapbox/components/missing-indicator'; +import StatusActionBar from 'soapbox/components/status-action-bar'; +import { Icon, IconButton, HStack, Stack } from 'soapbox/components/ui'; import Audio from 'soapbox/features/audio'; +import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status'; +import Thread from 'soapbox/features/status/components/thread'; import Video from 'soapbox/features/video'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { isUserTouching } from 'soapbox/is-mobile'; +import { makeGetStatus } from 'soapbox/selectors'; import ImageLoader from '../image-loader'; @@ -18,16 +25,31 @@ import type { Attachment, Status } from 'soapbox/types/entities'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, - previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + expand: { id: 'lightbox.expand', defaultMessage: 'Expand' }, + minimize: { id: 'lightbox.minimize', defaultMessage: 'Minimize' }, next: { id: 'lightbox.next', defaultMessage: 'Next' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, }); +// you can't use 100vh, because the viewport height is taller +// than the visible part of the document in some mobile +// browsers when it's address bar is visible. +// https://developers.google.com/web/updates/2016/12/url-bar-resizing +const swipeableViewsStyle: React.CSSProperties = { + width: '100%', + height: '100%', +}; + +const containerStyle: React.CSSProperties = { + alignItems: 'center', // center vertically +}; + interface IMediaModal { media: ImmutableList status?: Status index: number time?: number - onClose: () => void + onClose(): void } const MediaModal: React.FC = (props) => { @@ -38,29 +60,26 @@ const MediaModal: React.FC = (props) => { time = 0, } = props; - const intl = useIntl(); - const history = useHistory(); const dispatch = useAppDispatch(); + const history = useHistory(); + const intl = useIntl(); + const getStatus = useCallback(makeGetStatus(), []); + const actualStatus = useAppSelector((state) => getStatus(state, { id: status?.id as string })); + + const [isLoaded, setIsLoaded] = useState(!!status); + const [next, setNext] = useState(); const [index, setIndex] = useState(null); const [navigationHidden, setNavigationHidden] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(!status); - const handleSwipe = (index: number) => { - setIndex(index % media.size); - }; + const hasMultipleImages = media.size > 1; - const handleNextClick = () => { - setIndex((getIndex() + 1) % media.size); - }; + const handleSwipe = (index: number) => setIndex(index % media.size); + const handleNextClick = () => setIndex((getIndex() + 1) % media.size); + const handlePrevClick = () => setIndex((media.size + getIndex() - 1) % media.size); - const handlePrevClick = () => { - setIndex((media.size + getIndex() - 1) % media.size); - }; - - const handleChangeIndex: React.MouseEventHandler = (e) => { - const index = Number(e.currentTarget.getAttribute('data-index')); - setIndex(index % media.size); - }; + const navigationHiddenClassName = navigationHidden ? 'pointer-events-none opacity-0' : ''; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { @@ -77,18 +96,15 @@ const MediaModal: React.FC = (props) => { } }; - useEffect(() => { - window.addEventListener('keydown', handleKeyDown, false); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [index]); + const handleDownload = () => { + const mediaItem = hasMultipleImages ? media.get(index as number) : media.get(0); + window.open(mediaItem?.url); + }; const getIndex = () => index !== null ? index : props.index; const toggleNavigation = () => { - setNavigationHidden(!navigationHidden); + setNavigationHidden(value => !value && isUserTouching()); }; const handleStatusClick: React.MouseEventHandler = e => { @@ -105,61 +121,6 @@ const MediaModal: React.FC = (props) => { } }; - const handleCloserClick: React.MouseEventHandler = ({ target }) => { - const whitelist = ['zoomable-image']; - const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]'); - - const isClickOutside = target === activeSlide || !activeSlide?.contains(target as Element); - const isWhitelisted = whitelist.some(w => (target as Element).classList.contains(w)); - - if (isClickOutside || isWhitelisted) { - onClose(); - } - }; - - let pagination: React.ReactNode[] = []; - - const leftNav = media.size > 1 && ( - - ); - - const rightNav = media.size > 1 && ( - - ); - - if (media.size > 1) { - pagination = media.toArray().map((item, i) => ( -
  • - -
  • - )); - } - - const isMultiMedia = media.map((image) => image.type !== 'image').toArray(); - const content = media.map((attachment, i) => { const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined; const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined; @@ -230,62 +191,177 @@ const MediaModal: React.FC = (props) => { return null; }).toArray(); - // you can't use 100vh, because the viewport height is taller - // than the visible part of the document in some mobile - // browsers when it's address bar is visible. - // https://developers.google.com/web/updates/2016/12/url-bar-resizing - const swipeableViewsStyle: React.CSSProperties = { - width: '100%', - height: '100%', + const handleLoadMore = useCallback(debounce(() => { + if (next && status) { + dispatch(fetchNext(status?.id, next)).then(({ next }) => { + setNext(next); + }).catch(() => { }); + } + }, 300, { leading: true }), [next, status]); + + /** Fetch the status (and context) from the API. */ + const fetchData = async () => { + const { next } = await dispatch(fetchStatusWithContext(status?.id as string)); + setNext(next); }; - const containerStyle: React.CSSProperties = { - alignItems: 'center', // center vertically - }; + // Load data. + useEffect(() => { + fetchData().then(() => { + setIsLoaded(true); + }).catch(() => { + setIsLoaded(true); + }); + }, [status?.id]); - const navigationClassName = clsx('media-modal__navigation', { - 'media-modal__navigation--hidden': navigationHidden, - }); + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, false); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [index]); + + if (status) { + if (!actualStatus && isLoaded) { + return ( + + ); + } else if (!actualStatus) { + return ; + } + } + + const handleClickOutside: React.MouseEventHandler = (e) => { + if ((e.target as HTMLElement).tagName === 'DIV') { + onClose(); + } + }; return ( -
    +
    - - {content} - -
    + + -
    - + + - {leftNav} - {rightNav} + {status && ( + setIsFullScreen(!isFullScreen)} + /> + )} + + - {(status && !isMultiMedia[getIndex()]) && ( -
    1 })}> - - - + {/* Height based on height of top/bottom bars */} +
    + {hasMultipleImages && ( +
    + +
    + )} + + + {content} + + + {hasMultipleImages && ( +
    + +
    + )} +
    + + {actualStatus && ( + + + + )} + + + {actualStatus && ( + )} - -
      - {pagination} -
    ); diff --git a/app/soapbox/features/ui/components/panels/my-groups-panel.tsx b/app/soapbox/features/ui/components/panels/my-groups-panel.tsx index c732f5ae7..d9a95a314 100644 --- a/app/soapbox/features/ui/components/panels/my-groups-panel.tsx +++ b/app/soapbox/features/ui/components/panels/my-groups-panel.tsx @@ -19,7 +19,7 @@ const MyGroupsPanel = () => { > {isFetching ? ( new Array(3).fill(0).map((_, idx) => ( - + )) ) : ( groups.slice(0, 3).map((group) => ( diff --git a/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx b/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx index 5ef131047..d2671bc14 100644 --- a/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx +++ b/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx @@ -19,7 +19,7 @@ const SuggestedGroupsPanel = () => { > {isFetching ? ( new Array(3).fill(0).map((_, idx) => ( - + )) ) : ( groups.slice(0, 3).map((group) => ( diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 64482f42a..3861c973d 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -334,6 +334,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groups && } {features.groups && } {features.groups && } + {features.groups && } diff --git a/app/soapbox/features/ui/util/fullscreen.ts b/app/soapbox/features/ui/util/fullscreen.ts index 5e13d68cc..b04092f0b 100644 --- a/app/soapbox/features/ui/util/fullscreen.ts +++ b/app/soapbox/features/ui/util/fullscreen.ts @@ -33,26 +33,4 @@ export const requestFullscreen = (el: Element): void => { // @ts-ignore el.mozRequestFullScreen(); } -}; - -type FullscreenListener = (this: Document, ev: Event) => void; - -export const attachFullscreenListener = (listener: FullscreenListener): void => { - if ('onfullscreenchange' in document) { - document.addEventListener('fullscreenchange', listener); - } else if ('onwebkitfullscreenchange' in document) { - document.addEventListener('webkitfullscreenchange', listener); - } else if ('onmozfullscreenchange' in document) { - document.addEventListener('mozfullscreenchange', listener); - } -}; - -export const detachFullscreenListener = (listener: FullscreenListener): void => { - if ('onfullscreenchange' in document) { - document.removeEventListener('fullscreenchange', listener); - } else if ('onwebkitfullscreenchange' in document) { - document.removeEventListener('webkitfullscreenchange', listener); - } else if ('onmozfullscreenchange' in document) { - document.removeEventListener('mozfullscreenchange', listener); - } -}; +}; \ No newline at end of file diff --git a/app/soapbox/features/verification/steps/age-verification.tsx b/app/soapbox/features/verification/steps/age-verification.tsx index faf3c6c50..5129f5464 100644 --- a/app/soapbox/features/verification/steps/age-verification.tsx +++ b/app/soapbox/features/verification/steps/age-verification.tsx @@ -61,7 +61,7 @@ const AgeVerification = () => { = {}): Card { function buildGroup(props: Partial = {}): Group { return groupSchema.parse(Object.assign({ id: uuidv4(), + owner: { + id: uuidv4(), + }, }, props)); } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 57e3ecb8b..6bf4749f9 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -157,7 +157,7 @@ "admin_nav.awaiting_approval": "Waitlist", "admin_nav.dashboard": "Dashboard", "admin_nav.reports": "Reports", - "age_verification.body": "{siteTitle} requires users to be at least {ageMinimum, plural, one {# year} other {# years}} years old to access its platform. Anyone under the age of {ageMinimum, plural, one {# year} other {# years}} old cannot access this platform.", + "age_verification.body": "{siteTitle} requires users to be at least {ageMinimum, plural, one {# year} other {# years}} old to access its platform. Anyone under the age of {ageMinimum, plural, one {# year} other {# years}} old cannot access this platform.", "age_verification.fail": "You must be {ageMinimum, plural, one {# year} other {# years}} old or older.", "age_verification.header": "Enter your birth date", "alert.unexpected.body": "We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out).", @@ -388,6 +388,7 @@ "compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}", "compose.edit_success": "Your post was edited", "compose.invalid_schedule": "You must schedule a post at least 5 minutes out.", + "compose.reply_group_indicator.message": "Posting to {groupLink}", "compose.submit_success": "Your post was sent!", "compose_event.create": "Create", "compose_event.edit_success": "Your event was edited", @@ -763,7 +764,7 @@ "gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.", "gdpr.title": "{siteTitle} uses cookies", "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", - "group.banned.message": "You are banned from", + "group.banned.message": "You are banned from {group}", "group.cancel_request": "Cancel Request", "group.delete.success": "Group successfully deleted", "group.deleted.message": "This group has been deleted.", @@ -787,7 +788,7 @@ "group.leave.label": "Leave", "group.leave.success": "Left the group", "group.manage": "Manage Group", - "group.member.admin.limit.summary": "You can assign up to {count} admins for the group at this time.", + "group.member.admin.limit.summary": "You can assign up to {count, plural, one {admin} other {admins}} for the group at this time.", "group.member.admin.limit.title": "Admin limit reached", "group.popover.action": "View Group", "group.popover.summary": "You must be a member of the group in order to reply to this status.", @@ -853,6 +854,7 @@ "groups.pending.label": "Pending Requests", "groups.popular.label": "Suggested Groups", "groups.search.placeholder": "Search My Groups", + "groups.suggested.label": "Suggested Groups", "groups.tags.title": "Browse Topics", "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", @@ -926,6 +928,8 @@ "landing_page_modal.download": "Download", "landing_page_modal.helpCenter": "Help Center", "lightbox.close": "Cancel", + "lightbox.expand": "Expand", + "lightbox.minimize": "Minimize", "lightbox.next": "Next", "lightbox.previous": "Previous", "lightbox.view_context": "View context", @@ -1459,6 +1463,8 @@ "status.mute_conversation": "Mute Conversation", "status.open": "Show Post Details", "status.pin": "Pin on profile", + "status.pin_to_group": "Pin to Group", + "status.pin_to_group.success": "Pinned to Group!", "status.pinned": "Pinned post", "status.quote": "Quote post", "status.reactions.cry": "Sad", @@ -1495,6 +1501,7 @@ "status.unbookmarked": "Bookmark removed.", "status.unmute_conversation": "Unmute Conversation", "status.unpin": "Unpin from profile", + "status.unpin_to_group": "Unpin from Group", "status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}", "statuses.quote_tombstone": "Post is unavailable.", "statuses.tombstone": "One or more posts are unavailable.", diff --git a/app/soapbox/normalizers/group.ts b/app/soapbox/normalizers/group.ts index 8e2053674..9bdd501f6 100644 --- a/app/soapbox/normalizers/group.ts +++ b/app/soapbox/normalizers/group.ts @@ -32,6 +32,9 @@ export const GroupRecord = ImmutableRecord({ locked: false, membership_required: false, members_count: 0, + owner: { + id: '', + }, note: '', statuses_visibility: 'public', slug: '', diff --git a/app/soapbox/normalizers/notification.ts b/app/soapbox/normalizers/notification.ts index 2412db05d..45eb93fb3 100644 --- a/app/soapbox/normalizers/notification.ts +++ b/app/soapbox/normalizers/notification.ts @@ -25,8 +25,19 @@ export const NotificationRecord = ImmutableRecord({ total_count: null as number | null, // grouped notifications }); +const normalizeType = (notification: ImmutableMap) => { + if (notification.get('type') === 'group_mention') { + return notification.set('type', 'mention'); + } + + return notification; +}; + export const normalizeNotification = (notification: Record) => { return NotificationRecord( - ImmutableMap(fromJS(notification)), + ImmutableMap(fromJS(notification)) + .withMutations((notification: ImmutableMap) => { + normalizeType(notification); + }), ); }; diff --git a/app/soapbox/normalizers/soapbox/soapbox-config.ts b/app/soapbox/normalizers/soapbox/soapbox-config.ts index f73111611..d003f75e0 100644 --- a/app/soapbox/normalizers/soapbox/soapbox-config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox-config.ts @@ -126,7 +126,7 @@ type SoapboxConfigMap = ImmutableMap; const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { if (soapboxConfig.has('ads')) { - const ads = filteredArray(adSchema).parse(soapboxConfig.get('ads').toJS()); + const ads = filteredArray(adSchema).parse(ImmutableList(soapboxConfig.get('ads')).toJS()); return soapboxConfig.set('ads', ads); } else { return soapboxConfig; diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index 41d561604..1803de51c 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -82,10 +82,11 @@ const BlockedBlankslate = ({ group }: { group: Group }) => ( , + }} /> - {' '} - ); @@ -138,7 +139,7 @@ const GroupPage: React.FC = ({ params, children }) => { ); return items; - }, [features.groupsTags, pending.length]); + }, [features.groupsTags, pending.length, group?.slug]); const renderChildren = () => { if (isDeleted) { @@ -159,6 +160,7 @@ const GroupPage: React.FC = ({ params, children }) => { diff --git a/app/soapbox/reducers/notifications.ts b/app/soapbox/reducers/notifications.ts index 24185ee0a..f563fce83 100644 --- a/app/soapbox/reducers/notifications.ts +++ b/app/soapbox/reducers/notifications.ts @@ -88,12 +88,12 @@ const isValid = (notification: APIEntity) => { } // https://gitlab.com/soapbox-pub/soapbox/-/issues/424 - if (!notification.account.id) { + if (!notification.account.get('id')) { return false; } // Mastodon can return status notifications with a null status - if (['mention', 'reblog', 'favourite', 'poll', 'status'].includes(notification.type) && !notification.status.id) { + if (['mention', 'reblog', 'favourite', 'poll', 'status'].includes(notification.type) && !notification.status.get('id')) { return false; } @@ -131,6 +131,7 @@ const importNotification = (state: State, notification: APIEntity) => { export const processRawNotifications = (notifications: APIEntity[]) => ( ImmutableOrderedMap( notifications + .map(normalizeNotification) .filter(isValid) .map(n => [n.id, fixNotification(n)]), )); diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index 919013329..6c41bebc4 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -2,123 +2,148 @@ import escapeTextContentForBrowser from 'escape-html'; import z from 'zod'; import emojify from 'soapbox/features/emoji'; +import { unescapeHTML } from 'soapbox/utils/html'; import { customEmojiSchema } from './custom-emoji'; -import { relationshipSchema } from './relationship'; import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; +import type { Resolve } from 'soapbox/utils/types'; + const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); -const accountSchema = z.object({ - accepting_messages: z.boolean().catch(false), - accepts_chat_messages: z.boolean().catch(false), +const birthdaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +const fieldSchema = z.object({ + name: z.string(), + value: z.string(), + verified_at: z.string().datetime().nullable().catch(null), +}); + +const baseAccountSchema = z.object({ acct: z.string().catch(''), avatar: z.string().catch(avatarMissing), - avatar_static: z.string().catch(''), - birthday: z.string().catch(''), + avatar_static: z.string().url().optional().catch(undefined), bot: z.boolean().catch(false), - chats_onboarded: z.boolean().catch(true), created_at: z.string().datetime().catch(new Date().toUTCString()), discoverable: z.boolean().catch(false), display_name: z.string().catch(''), emojis: filteredArray(customEmojiSchema), favicon: z.string().catch(''), - fields: z.any(), // TODO + fields: filteredArray(fieldSchema), followers_count: z.number().catch(0), following_count: z.number().catch(0), - fqn: z.string().catch(''), - header: z.string().catch(headerMissing), - header_static: z.string().catch(''), + fqn: z.string().optional().catch(undefined), + header: z.string().url().catch(headerMissing), + header_static: z.string().url().optional().catch(undefined), id: z.string(), - last_status_at: z.string().catch(''), - location: z.string().catch(''), + last_status_at: z.string().datetime().optional().catch(undefined), + location: z.string().optional().catch(undefined), locked: z.boolean().catch(false), - moved: z.any(), // TODO + moved: z.literal(null).catch(null), mute_expires_at: z.union([ z.string(), z.null(), ]).catch(null), note: contentSchema, - pleroma: z.any(), // TODO - source: z.any(), // TODO + /** Fedibird extra settings. */ + other_settings: z.object({ + birthday: birthdaySchema.nullish().catch(undefined), + location: z.string().optional().catch(undefined), + }).optional().catch(undefined), + pleroma: z.object({ + accepts_chat_messages: z.boolean().catch(false), + accepts_email_list: z.boolean().catch(false), + birthday: birthdaySchema.nullish().catch(undefined), + deactivated: z.boolean().catch(false), + favicon: z.string().url().optional().catch(undefined), + hide_favorites: z.boolean().catch(false), + hide_followers: z.boolean().catch(false), + hide_followers_count: z.boolean().catch(false), + hide_follows: z.boolean().catch(false), + hide_follows_count: z.boolean().catch(false), + is_admin: z.boolean().catch(false), + is_moderator: z.boolean().catch(false), + is_suggested: z.boolean().catch(false), + location: z.string().optional().catch(undefined), + notification_settings: z.object({ + block_from_strangers: z.boolean().catch(false), + }).optional().catch(undefined), + tags: z.array(z.string()).catch([]), + }).optional().catch(undefined), + source: z.object({ + approved: z.boolean().catch(true), + chats_onboarded: z.boolean().catch(true), + fields: filteredArray(fieldSchema), + note: z.string().catch(''), + pleroma: z.object({ + discoverable: z.boolean().catch(true), + }).optional().catch(undefined), + sms_verified: z.boolean().catch(false), + }).optional().catch(undefined), statuses_count: z.number().catch(0), + suspended: z.boolean().catch(false), uri: z.string().url().catch(''), url: z.string().url().catch(''), username: z.string().catch(''), - verified: z.boolean().default(false), + verified: z.boolean().catch(false), website: z.string().catch(''), - - /** - * Internal fields - */ - display_name_html: z.string().catch(''), - domain: z.string().catch(''), - note_emojified: z.string().catch(''), - relationship: relationshipSchema.nullable().catch(null), - - /** - * Misc - */ - other_settings: z.any(), -}).transform((account) => { - const customEmojiMap = makeCustomEmojiMap(account.emojis); - - // Birthday - const birthday = account.pleroma?.birthday || account.other_settings?.birthday; - account.birthday = birthday; - - // Verified - const verified = account.verified === true || account.pleroma?.tags?.includes('verified'); - account.verified = verified; - - // Location - const location = account.location - || account.pleroma?.location - || account.other_settings?.location; - account.location = location; - - // Username - const acct = account.acct || ''; - const username = account.username || ''; - account.username = username || acct.split('@')[0]; - - // Display Name - const displayName = account.display_name || ''; - account.display_name = displayName.trim().length === 0 ? account.username : displayName; - account.display_name_html = emojify(escapeTextContentForBrowser(displayName), customEmojiMap); - - // Discoverable - const discoverable = Boolean(account.discoverable || account.source?.pleroma?.discoverable); - account.discoverable = discoverable; - - // Message Acceptance - const acceptsChatMessages = Boolean(account.pleroma?.accepts_chat_messages || account?.accepting_messages); - account.accepts_chat_messages = acceptsChatMessages; - - // Notes - account.note_emojified = emojify(account.note, customEmojiMap); - - /** - * Todo - * - internal fields - * - donor - * - tags - * - fields - * - pleroma legacy fields - * - emojification - * - domain - * - guessFqn - * - fqn - * - favicon - * - staff fields - * - birthday - * - note - */ - - return account; }); -type Account = z.infer; +type BaseAccount = z.infer; +type TransformableAccount = Omit; + +const getDomain = (url: string) => { + try { + return new URL(url).host; + } catch (e) { + return ''; + } +}; + +/** Add internal fields to the account. */ +const transformAccount = ({ pleroma, other_settings, fields, ...account }: T) => { + const customEmojiMap = makeCustomEmojiMap(account.emojis); + + const newFields = fields.map((field) => ({ + ...field, + name_emojified: emojify(escapeTextContentForBrowser(field.name), customEmojiMap), + value_emojified: emojify(field.value, customEmojiMap), + value_plain: unescapeHTML(field.value), + })); + + const domain = getDomain(account.url || account.uri); + + if (pleroma) { + pleroma.birthday = pleroma.birthday || other_settings?.birthday; + } + + return { + ...account, + admin: pleroma?.is_admin || false, + avatar_static: account.avatar_static || account.avatar, + discoverable: account.discoverable || account.source?.pleroma?.discoverable || false, + display_name: account.display_name.trim().length === 0 ? account.username : account.display_name, + display_name_html: emojify(escapeTextContentForBrowser(account.display_name), customEmojiMap), + domain, + fields: newFields, + fqn: account.fqn || (account.acct.includes('@') ? account.acct : `${account.acct}@${domain}`), + header_static: account.header_static || account.header, + moderator: pleroma?.is_moderator || false, + location: account.location || pleroma?.location || other_settings?.location || '', + note_emojified: emojify(account.note, customEmojiMap), + pleroma, + relationship: undefined, + staff: pleroma?.is_admin || pleroma?.is_moderator || false, + suspended: account.suspended || pleroma?.deactivated || false, + verified: account.verified || pleroma?.tags.includes('verified') || false, + }; +}; + +const accountSchema = baseAccountSchema.extend({ + moved: baseAccountSchema.transform(transformAccount).nullable().catch(null), +}).transform(transformAccount); + +type Account = Resolve>; export { accountSchema, type Account }; \ No newline at end of file diff --git a/app/soapbox/schemas/attachment.ts b/app/soapbox/schemas/attachment.ts index 44b9cb126..3df39d542 100644 --- a/app/soapbox/schemas/attachment.ts +++ b/app/soapbox/schemas/attachment.ts @@ -62,6 +62,12 @@ const audioAttachmentSchema = baseAttachmentSchema.extend({ type: z.literal('audio'), meta: z.object({ duration: z.number().optional().catch(undefined), + colors: z.object({ + background: z.string().optional().catch(undefined), + foreground: z.string().optional().catch(undefined), + accent: z.string().optional().catch(undefined), + duration: z.number().optional().catch(undefined), + }).optional().catch(undefined), }).catch({}), }); diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts index 1559148e1..56998c625 100644 --- a/app/soapbox/schemas/emoji-reaction.ts +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -7,6 +7,8 @@ const emojiReactionSchema = z.object({ name: emojiSchema, count: z.number().nullable().catch(null), me: z.boolean().catch(false), + /** Akkoma custom emoji reaction. */ + url: z.string().url().optional().catch(undefined), }); type EmojiReaction = z.infer; diff --git a/app/soapbox/schemas/event.ts b/app/soapbox/schemas/event.ts new file mode 100644 index 000000000..e74a80760 --- /dev/null +++ b/app/soapbox/schemas/event.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { attachmentSchema } from './attachment'; +import { locationSchema } from './location'; + +const eventSchema = z.object({ + name: z.string().catch(''), + start_time: z.string().datetime().nullable().catch(null), + end_time: z.string().datetime().nullable().catch(null), + join_mode: z.enum(['free', 'restricted', 'invite']).nullable().catch(null), + participants_count: z.number().catch(0), + location: locationSchema.nullable().catch(null), + join_state: z.enum(['pending', 'reject', 'accept']).nullable().catch(null), + banner: attachmentSchema.nullable().catch(null), + links: z.array(attachmentSchema).nullable().catch(null), +}); + +type Event = z.infer; + +export { eventSchema, type Event }; \ No newline at end of file diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index d5ee7f2ee..be9238308 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -27,6 +27,7 @@ const groupSchema = z.object({ locked: z.boolean().catch(false), membership_required: z.boolean().catch(false), members_count: z.number().catch(0), + owner: z.object({ id: z.string() }), note: z.string().transform(note => note === '

    ' ? '' : note).catch(''), relationship: groupRelationshipSchema.nullable().catch(null), // Dummy field to be overwritten later slug: z.string().catch(''), // TruthSocial diff --git a/app/soapbox/schemas/location.ts b/app/soapbox/schemas/location.ts new file mode 100644 index 000000000..a0435f1f3 --- /dev/null +++ b/app/soapbox/schemas/location.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const locationSchema = z.object({ + url: z.string().url().catch(''), + description: z.string().catch(''), + country: z.string().catch(''), + locality: z.string().catch(''), + region: z.string().catch(''), + postal_code: z.string().catch(''), + street: z.string().catch(''), + origin_id: z.string().catch(''), + origin_provider: z.string().catch(''), + type: z.string().catch(''), + timezone: z.string().catch(''), + name: z.string().catch(''), + latitude: z.number().catch(0), + longitude: z.number().catch(0), + geom: z.object({ + coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null), + srid: z.string().catch(''), + }).nullable().catch(null), +}); + +type Location = z.infer; + +export { locationSchema, type Location }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index ea55d5085..accd31a6a 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -1,17 +1,28 @@ +import escapeTextContentForBrowser from 'escape-html'; import { z } from 'zod'; +import emojify from 'soapbox/features/emoji'; +import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html'; + import { accountSchema } from './account'; import { attachmentSchema } from './attachment'; import { cardSchema } from './card'; import { customEmojiSchema } from './custom-emoji'; +import { emojiReactionSchema } from './emoji-reaction'; +import { eventSchema } from './event'; import { groupSchema } from './group'; import { mentionSchema } from './mention'; import { pollSchema } from './poll'; import { tagSchema } from './tag'; -import { contentSchema, dateSchema, filteredArray } from './utils'; +import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; -const tombstoneSchema = z.object({ - reason: z.enum(['deleted']), +import type { Resolve } from 'soapbox/utils/types'; + +const statusPleromaSchema = z.object({ + emoji_reactions: filteredArray(emojiReactionSchema), + event: eventSchema.nullish().catch(undefined), + quote: z.literal(null).catch(null), + quote_visible: z.boolean().catch(true), }); const baseStatusSchema = z.object({ @@ -39,7 +50,7 @@ const baseStatusSchema = z.object({ mentions: filteredArray(mentionSchema), muted: z.coerce.boolean(), pinned: z.coerce.boolean(), - pleroma: z.object({}).optional().catch(undefined), + pleroma: statusPleromaSchema.optional().catch(undefined), poll: pollSchema.nullable().catch(null), quote: z.literal(null).catch(null), quotes_count: z.number().catch(0), @@ -50,17 +61,91 @@ const baseStatusSchema = z.object({ sensitive: z.coerce.boolean(), spoiler_text: contentSchema, tags: filteredArray(tagSchema), - tombstone: tombstoneSchema.nullable().optional(), + tombstone: z.object({ + reason: z.enum(['deleted']), + }).nullable().optional().catch(undefined), uri: z.string().url().catch(''), url: z.string().url().catch(''), visibility: z.string().catch('public'), }); -const statusSchema = baseStatusSchema.extend({ - quote: baseStatusSchema.nullable().catch(null), - reblog: baseStatusSchema.nullable().catch(null), -}); +type BaseStatus = z.infer; +type TransformableStatus = Omit & { + pleroma?: Omit, 'quote'> +}; -type Status = z.infer; +/** Creates search index from the status. */ +const buildSearchIndex = (status: TransformableStatus): string => { + const pollOptionTitles = status.poll ? status.poll.options.map(({ title }) => title) : []; + const mentionedUsernames = status.mentions.map(({ acct }) => `@${acct}`); + + const fields = [ + status.spoiler_text, + status.content, + ...pollOptionTitles, + ...mentionedUsernames, + ]; + + const searchContent = unescapeHTML(fields.join('\n\n')) || ''; + return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || ''; +}; + +type Translation = { + content: string + provider: string +} + +/** Add internal fields to the status. */ +const transformStatus = ({ pleroma, ...status }: T) => { + const emojiMap = makeCustomEmojiMap(status.emojis); + + const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap)); + const spoilerHtml = emojify(escapeTextContentForBrowser(status.spoiler_text), emojiMap); + + return { + ...status, + approval_status: 'approval' as const, + contentHtml, + expectsCard: false, + event: pleroma?.event, + filtered: [], + hidden: false, + pleroma: pleroma ? (() => { + const { event, ...rest } = pleroma; + return rest; + })() : undefined, + search_index: buildSearchIndex(status), + showFiltered: false, // TODO: this should be removed from the schema and done somewhere else + spoilerHtml, + translation: undefined as Translation | undefined, + }; +}; + +const embeddedStatusSchema = baseStatusSchema + .transform(transformStatus) + .nullable() + .catch(null); + +const statusSchema = baseStatusSchema.extend({ + quote: embeddedStatusSchema, + reblog: embeddedStatusSchema, + pleroma: statusPleromaSchema.extend({ + quote: embeddedStatusSchema, + }).optional().catch(undefined), +}).transform(({ pleroma, ...status }) => { + return { + ...status, + event: pleroma?.event, + quote: pleroma?.quote || status.quote || null, + // There's apparently no better way to do this... + // Just trying to remove the `event` and `quote` keys from the object. + pleroma: pleroma ? (() => { + const { event, quote, ...rest } = pleroma; + return rest; + })() : undefined, + }; +}).transform(transformStatus); + +type Status = Resolve>; export { statusSchema, type Status }; \ No newline at end of file diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index 47781ffbf..ef7c446a5 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -23,7 +23,7 @@ export const getBaseURL = (account: AccountEntity): string => { } }; -export const getAcct = (account: AccountEntity | Account, displayFqn: boolean): string => ( +export const getAcct = (account: Pick, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct ); diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index be3650fc1..1b42dd539 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -550,7 +550,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/admin/groups/:group_id/unsuspend * @see DELETE /api/v1/admin/groups/:group_id */ - groups: v.build === UNRELEASED, + groups: v.software === TRUTHSOCIAL, /** * Cap # of Group Admins to 5 diff --git a/app/soapbox/utils/greentext.ts b/app/soapbox/utils/greentext.ts deleted file mode 100644 index 70c5e05d8..000000000 --- a/app/soapbox/utils/greentext.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { processHtml } from './tiny-post-html-processor'; - -export const addGreentext = (html: string): string => { - // Copied from Pleroma FE - // https://git.pleroma.social/pleroma/pleroma-fe/-/blob/19475ba356c3fd6c54ca0306d3ae392358c212d1/src/components/status_content/status_content.js#L132 - return processHtml(html, (string) => { - try { - if (string.includes('>') && - string - .replace(/<[^>]+?>/gi, '') // remove all tags - .replace(/@\w+/gi, '') // remove mentions (even failed ones) - .trim() - .startsWith('>')) { - return `${string}`; - } else { - return string; - } - } catch (e) { - return string; - } - }); -}; diff --git a/app/soapbox/utils/tiny-post-html-processor.ts b/app/soapbox/utils/tiny-post-html-processor.ts deleted file mode 100644 index 5c740ced3..000000000 --- a/app/soapbox/utils/tiny-post-html-processor.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Copied from Pleroma FE -// https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js - -type Processor = (html: string) => string; - -/** - * This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and - * allows it to be processed, useful for greentexting, mostly. - * - * known issue: doesn't handle CDATA so nested CDATA might not work well. - */ -export const processHtml = (html: string, processor: Processor): string => { - const handledTags = new Set(['p', 'br', 'div']); - const openCloseTags = new Set(['p', 'div']); - - let buffer = ''; // Current output buffer - const level: string[] = []; // How deep we are in tags and which tags were there - let textBuffer = ''; // Current line content - let tagBuffer = null; // Current tag buffer, if null = we are not currently reading a tag - - // Extracts tag name from tag, i.e. => span - const getTagName = (tag: string): string | null => { - const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag); - return result && (result[1] || result[2]); - }; - - const flush = (): void => { // Processes current line buffer, adds it to output buffer and clears line buffer - if (textBuffer.trim().length > 0) { - buffer += processor(textBuffer); - } else { - buffer += textBuffer; - } - textBuffer = ''; - }; - - const handleBr = (tag: string): void => { // handles single newlines/linebreaks/selfclosing - flush(); - buffer += tag; - }; - - const handleOpen = (tag: string): void => { // handles opening tags - flush(); - buffer += tag; - level.push(tag); - }; - - const handleClose = (tag: string): void => { // handles closing tags - flush(); - buffer += tag; - if (level[level.length - 1] === tag) { - level.pop(); - } - }; - - for (let i = 0; i < html.length; i++) { - const char = html[i]; - if (char === '<' && tagBuffer === null) { - tagBuffer = char; - } else if (char !== '>' && tagBuffer !== null) { - tagBuffer += char; - } else if (char === '>' && tagBuffer !== null) { - tagBuffer += char; - const tagFull = tagBuffer; - tagBuffer = null; - const tagName = getTagName(tagFull); - if (tagName && handledTags.has(tagName)) { - if (tagName === 'br') { - handleBr(tagFull); - } else if (openCloseTags.has(tagName)) { - if (tagFull[1] === '/') { - handleClose(tagFull); - } else if (tagFull[tagFull.length - 2] === '/') { - // self-closing - handleBr(tagFull); - } else { - handleOpen(tagFull); - } - } - } else { - textBuffer += tagFull; - } - } else if (char === '\n') { - handleBr(char); - } else { - textBuffer += char; - } - } - if (tagBuffer) { - textBuffer += tagBuffer; - } - - flush(); - - return buffer; -}; diff --git a/app/soapbox/utils/types.ts b/app/soapbox/utils/types.ts new file mode 100644 index 000000000..31eacd481 --- /dev/null +++ b/app/soapbox/utils/types.ts @@ -0,0 +1,7 @@ +/** + * Resolve a type into a flat POJO interface if it's been wrapped by generics. + * https://gleasonator.com/@alex/posts/AWfK4hyppMDCqrT2y8 + */ +type Resolve = Pick; + +export type { Resolve }; \ No newline at end of file diff --git a/app/styles/components/detailed-status.scss b/app/styles/components/detailed-status.scss index daa7296ee..65d429a19 100644 --- a/app/styles/components/detailed-status.scss +++ b/app/styles/components/detailed-status.scss @@ -1,5 +1,5 @@ .thread { - @apply bg-white dark:bg-primary-900 sm:rounded-xl; + @apply bg-white dark:bg-primary-900; &__status { @apply relative pb-4; diff --git a/app/styles/components/modal.scss b/app/styles/components/modal.scss index b236c4428..b4c31ff4f 100644 --- a/app/styles/components/modal.scss +++ b/app/styles/components/modal.scss @@ -7,9 +7,6 @@ } .media-modal { - // https://stackoverflow.com/a/8468131 - @apply w-full h-full absolute inset-0; - .audio-player.detailed, .extended-video-player { display: flex; @@ -30,126 +27,6 @@ @apply max-w-full max-h-[80%]; } } - - &__closer { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - } - - &__navigation { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - transition: opacity 0.3s linear; - will-change: opacity; - - * { - pointer-events: auto; - } - - &--hidden { - opacity: 0; - - * { - pointer-events: none; - } - } - } - - &__nav { - @apply absolute top-0 bottom-0 my-auto mx-0 box-border flex h-[20vmax] cursor-pointer items-center border-0 bg-black/50 text-2xl text-white; - padding: 30px 15px; - - @media screen and (max-width: 600px) { - @apply px-0.5; - } - - .svg-icon { - @apply h-6 w-6; - } - - &--left { - left: 0; - } - - &--right { - right: 0; - } - } - - &__pagination { - width: 100%; - text-align: center; - position: absolute; - left: 0; - bottom: 20px; - pointer-events: none; - } - - &__meta { - text-align: center; - position: absolute; - left: 0; - bottom: 20px; - width: 100%; - pointer-events: none; - - &--shifted { - bottom: 62px; - } - - a { - text-decoration: none; - font-weight: 500; - color: #fff; - - &:hover, - &:focus, - &:active { - text-decoration: underline; - } - } - } - - &__page-dot { - display: inline-block; - } - - &__button { - background-color: #fff; - height: 12px; - width: 12px; - border-radius: 6px; - margin: 10px; - padding: 0; - border: 0; - font-size: 0; - - &--active { - @apply bg-accent-500; - } - } - - &__close { - position: absolute; - right: 8px; - top: 8px; - height: 48px; - width: 48px; - z-index: 100; - color: #fff; - - .svg-icon { - height: 48px; - width: 48px; - } - } } .error-modal { @@ -198,24 +75,6 @@ min-width: 33px; } } - - &__nav { - border: 0; - font-size: 14px; - font-weight: 500; - padding: 10px 25px; - line-height: inherit; - height: auto; - margin: -10px; - border-radius: 4px; - background-color: transparent; - - &:hover, - &:focus, - &:active { - @apply text-gray-400; - } - } } .actions-modal { diff --git a/app/styles/ui.scss b/app/styles/ui.scss index f5dfa7c57..a727a076c 100644 --- a/app/styles/ui.scss +++ b/app/styles/ui.scss @@ -36,24 +36,6 @@ } } -.invisible { - font-size: 0 !important; - line-height: 0 !important; - display: inline-block; - width: 0; - height: 0; - position: absolute; - - img, - svg { - margin: 0 !important; - border: 0 !important; - padding: 0 !important; - width: 0 !important; - height: 0 !important; - } -} - .react-datepicker-popper { z-index: 9999 !important; } diff --git a/package.json b/package.json index 167a5a33a..fba57648d 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "ts-node": "^10.9.1", "tslib": "^2.3.1", "twemoji": "https://github.com/twitter/twemoji#v14.0.2", - "typescript": "^4.4.4", + "typescript": "^5.1.3", "util": "^0.12.4", "uuid": "^9.0.0", "webpack": "^5.72.1", diff --git a/yarn.lock b/yarn.lock index 7d58a2872..d6b9f5e00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6688,15 +6688,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001254, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001366: - version "1.0.30001441" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz" - integrity sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg== - -caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001449: - version "1.0.30001450" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001450.tgz#022225b91200589196b814b51b1bbe45144cf74f" - integrity sha512-qMBmvmQmFXaSxexkjjfMvD5rnDL0+m+dUMZKoDYsGG8iZN29RuYh9eRoMvKsT6uMAWlyUUGDEQGJJYjzCIO9ew== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001254, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001366, caniuse-lite@^1.0.30001449: + version "1.0.30001502" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001502.tgz" + integrity sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg== capture-exit@^2.0.0: version "2.0.0" @@ -17314,10 +17309,10 @@ typescript@^4.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324" integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA== -typescript@^4.4.4: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.3.tgz#8d84219244a6b40b6fb2b33cc1c062f715b9e826" + integrity sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39"