From 93b09d8206a2ea3ff4243a9a068249c0ed0505b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 19 Jan 2023 15:06:17 +0100 Subject: [PATCH 01/63] Add ability to follow hashtags in web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/tags.ts | 120 ++++++++++++++++++ app/soapbox/components/ui/card/card.tsx | 18 ++- app/soapbox/components/ui/column/column.tsx | 23 +++- .../features/hashtag-timeline/index.tsx | 28 +++- app/soapbox/normalizers/tag.ts | 1 + app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/tags.ts | 30 +++++ app/soapbox/utils/features.ts | 7 + 8 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 app/soapbox/actions/tags.ts create mode 100644 app/soapbox/reducers/tags.ts diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts new file mode 100644 index 000000000..2c394ba46 --- /dev/null +++ b/app/soapbox/actions/tags.ts @@ -0,0 +1,120 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; +const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; +const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; + +const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; +const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; +const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; + +const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; +const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; +const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; + +const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchHashtagRequest()); + + api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { + dispatch(fetchHashtagSuccess(name, data)); + }).catch(err => { + dispatch(fetchHashtagFail(err)); + }); +}; + +const fetchHashtagRequest = () => ({ + type: HASHTAG_FETCH_REQUEST, +}); + +const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FETCH_SUCCESS, + name, + tag, +}); + +const fetchHashtagFail = (error: AxiosError) => ({ + type: HASHTAG_FETCH_FAIL, + error, +}); + +const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(followHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { + dispatch(followHashtagSuccess(name, data)); + }).catch(err => { + dispatch(followHashtagFail(name, err)); + }); +}; + +const followHashtagRequest = (name: string) => ({ + type: HASHTAG_FOLLOW_REQUEST, + name, +}); + +const followHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FOLLOW_SUCCESS, + name, + tag, +}); + +const followHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_FOLLOW_FAIL, + name, + error, +}); + +const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(unfollowHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { + dispatch(unfollowHashtagSuccess(name, data)); + }).catch(err => { + dispatch(unfollowHashtagFail(name, err)); + }); +}; + +const unfollowHashtagRequest = (name: string) => ({ + type: HASHTAG_UNFOLLOW_REQUEST, + name, +}); + +const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_UNFOLLOW_SUCCESS, + name, + tag, +}); + +const unfollowHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_UNFOLLOW_FAIL, + name, + error, +}); + +export { + HASHTAG_FETCH_REQUEST, + HASHTAG_FETCH_SUCCESS, + HASHTAG_FETCH_FAIL, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_SUCCESS, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_SUCCESS, + HASHTAG_UNFOLLOW_FAIL, + fetchHashtag, + fetchHashtagRequest, + fetchHashtagSuccess, + fetchHashtagFail, + followHashtag, + followHashtagRequest, + followHashtagSuccess, + followHashtagFail, + unfollowHashtag, + unfollowHashtagRequest, + unfollowHashtagSuccess, + unfollowHashtagFail, +}; diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 6fc85a39a..2cc23b6b4 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -45,6 +45,12 @@ interface ICardHeader { backHref?: string, onBackClick?: (event: React.MouseEvent) => void className?: string + /** Callback when the card action is clicked. */ + onActionClick?: () => void, + /** URL to the svg icon for the card action. */ + actionIcon?: string, + /** Text for the action. */ + actionTitle?: string, children?: React.ReactNode } @@ -52,7 +58,7 @@ interface ICardHeader { * Card header container with back button. * Typically holds a CardTitle. */ -const CardHeader: React.FC = ({ className, children, backHref, onBackClick }): JSX.Element => { +const CardHeader: React.FC = ({ className, children, backHref, onBackClick, onActionClick, actionIcon, actionTitle }): JSX.Element => { const intl = useIntl(); const renderBackButton = () => { @@ -64,7 +70,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; return ( - + {intl.formatMessage(messages.back)} @@ -76,6 +82,12 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa {renderBackButton()} {children} + + {onActionClick && actionIcon && ( + + )} ); }; @@ -86,7 +98,7 @@ interface ICardTitle { /** A card's title. */ const CardTitle: React.FC = ({ title }): JSX.Element => ( - {title} + {title} ); interface ICardBody { diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 5ccf3ac42..7099b792d 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; -type IColumnHeader = Pick; +type IColumnHeader = Pick; /** Contains the column title with optional back button. */ -const ColumnHeader: React.FC = ({ label, backHref, className }) => { +const ColumnHeader: React.FC = ({ label, backHref, className, onActionClick, actionIcon, actionTitle }) => { const history = useHistory(); const handleBackClick = () => { @@ -27,7 +27,13 @@ const ColumnHeader: React.FC = ({ label, backHref, className }) = }; return ( - + ); @@ -46,13 +52,19 @@ export interface IColumn { className?: string, /** Ref forwarded to column. */ ref?: React.Ref + /** Callback when the column action is clicked. */ + onActionClick?: () => void, + /** URL to the svg icon for the column action. */ + actionIcon?: string, + /** Text for the action. */ + actionTitle?: string, /** Children to display in the column. */ children?: React.ReactNode } /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { - const { backHref, children, label, transparent = false, withHeader = true, className } = props; + const { backHref, children, label, transparent = false, withHeader = true, onActionClick, actionIcon, actionTitle, className } = props; const soapboxConfig = useSoapboxConfig(); return ( @@ -75,6 +87,9 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR label={label} backHref={backHref} className={classNames({ 'px-4 pt-4 sm:p-0': transparent })} + onActionClick={onActionClick} + actionIcon={actionIcon} + actionTitle={actionTitle} /> )} diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 133a96a5f..2587ff374 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; import { Column } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import type { Tag as TagEntity } from 'soapbox/types/entities'; @@ -18,6 +19,8 @@ const messages = defineMessages({ any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, }); @@ -32,9 +35,11 @@ export const HashtagTimeline: React.FC = ({ params }) => { const intl = useIntl(); const id = params?.id || ''; const tags = params?.tags || { any: [], all: [], none: [] }; - + + const features = useFeatures(); const dispatch = useAppDispatch(); const disconnects = useRef<(() => void)[]>([]); + const tag = useAppSelector((state) => state.tags.get(id)); // Mastodon supports displaying results from multiple hashtags. // https://github.com/mastodon/mastodon/issues/6359 @@ -88,9 +93,18 @@ export const HashtagTimeline: React.FC = ({ params }) => { dispatch(expandHashtagTimeline(id, { maxId, tags })); }; + const handleFollow = () => { + if (tag?.following) { + dispatch(unfollowHashtag(id)); + } else { + dispatch(followHashtag(id)); + } + }; + useEffect(() => { subscribe(); dispatch(expandHashtagTimeline(id, { tags })); + dispatch(fetchHashtag(id)); return () => { unsubscribe(); @@ -105,7 +119,13 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + = ({ params }) => { ); }; -export default HashtagTimeline; \ No newline at end of file +export default HashtagTimeline; diff --git a/app/soapbox/normalizers/tag.ts b/app/soapbox/normalizers/tag.ts index 6d0ebae14..fde58241f 100644 --- a/app/soapbox/normalizers/tag.ts +++ b/app/soapbox/normalizers/tag.ts @@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({ name: '', url: '', history: null as ImmutableList | null, + following: false, }); const normalizeHistoryList = (tag: ImmutableMap) => { diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 5b5cca999..1d1d71802 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -56,6 +56,7 @@ import status_hover_card from './status-hover-card'; import status_lists from './status-lists'; import statuses from './statuses'; import suggestions from './suggestions'; +import tags from './tags'; import timelines from './timelines'; import trending_statuses from './trending-statuses'; import trends from './trends'; @@ -120,6 +121,7 @@ const reducers = { announcements, compose_event, admin_user_index, + tags, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/tags.ts b/app/soapbox/reducers/tags.ts new file mode 100644 index 000000000..81488bb1e --- /dev/null +++ b/app/soapbox/reducers/tags.ts @@ -0,0 +1,30 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { + HASHTAG_FETCH_SUCCESS, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_FAIL, +} from 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { Tag } from 'soapbox/types/entities'; + +const initialState = ImmutableMap(); + +export default function tags(state = initialState, action: AnyAction) { + switch (action.type) { + case HASHTAG_FETCH_SUCCESS: + return state.set(action.name, normalizeTag(action.tag)); + case HASHTAG_FOLLOW_REQUEST: + case HASHTAG_UNFOLLOW_FAIL: + return state.setIn([action.name, 'following'], true); + case HASHTAG_FOLLOW_FAIL: + case HASHTAG_UNFOLLOW_REQUEST: + return state.setIn([action.name, 'following'], false); + default: + return state; + } +} diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 39950a534..2f24c07f4 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -447,6 +447,13 @@ const getInstanceFeatures = (instance: Instance) => { */ focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), + /** + * Ability to follow hashtags. + * @see POST /api/v1/tags/:name/follow + * @see POST /api/v1/tags/:name/unfollow + */ + followHashtags: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + /** * Ability to lock accounts and manually approve followers. * @see PATCH /api/v1/accounts/update_credentials From d891024cb54ab02f6410e4f2766f2e36ca3f887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 20 Jan 2023 14:20:50 +0100 Subject: [PATCH 02/63] Update en.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/locales/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 05613e17f..899020207 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -695,6 +695,8 @@ "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.none": "without {additional}", + "hashtag.follow": "Follow hashtag", + "hashtag.unfollow": "Unfollow hashtag", "header.home.label": "Home", "header.login.forgot_password": "Forgot password?", "header.login.label": "Log in", From 0ec5ec712977ef298e0169a4c82652f8a47be812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 20 Jan 2023 22:32:51 +0100 Subject: [PATCH 03/63] Update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdc24413..8eac50477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Admin: redirect the homepage to any URL. - Compatibility: added compatibility with Friendica. +- Hashtags: let users follow hashtags (Mastodon). ### Changed From d4bcdf428f65c5f4fcacc20c9d76e9ec6e1f174d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 4 Feb 2023 11:44:12 +0100 Subject: [PATCH 04/63] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 2f24c07f4..0068f17dd 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -463,6 +463,12 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA, ]), + /** + * Ability to list followed hashtags. + * @see GET /api/v1/followed_tags + */ + followedHashtagsList: v.software === MASTODON && gte(v.compatVersion, '4.1.0'), + /** * Whether client settings can be retrieved from the API. * @see GET /api/pleroma/frontend_configurations From 86a5753d10ce63cd021bce86bc5ac7a3b0849e4a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 2 Apr 2023 19:54:08 -0500 Subject: [PATCH 05/63] Sign nostr event from websocket --- app/soapbox/actions/streaming.ts | 11 +++++- app/soapbox/stream.ts | 16 ++++++--- app/soapbox/types/nostr.ts | 8 +++++ app/soapbox/types/window.d.ts | 7 ++++ package.json | 1 + yarn.lock | 61 ++++++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 app/soapbox/types/nostr.ts create mode 100644 app/soapbox/types/window.d.ts diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index f3e393f27..c96de40e3 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -95,7 +95,7 @@ const connectTimelineStream = ( dispatch(disconnectTimeline(timelineId)); }, - onReceive(data: any) { + onReceive(websocket, data: any) { switch (data.event) { case 'update': dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); @@ -181,6 +181,15 @@ const connectTimelineStream = ( case 'marker': dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); break; + case 'nostr:signEvent': + (async () => { + const event = await window.nostr?.signEvent(JSON.parse(data.payload)); + + if (event) { + websocket.send(JSON.stringify({ event: 'nostr:event', payload: event })); + } + })(); + break; } }, }; diff --git a/app/soapbox/stream.ts b/app/soapbox/stream.ts index 1a09a80e4..9370a20ee 100644 --- a/app/soapbox/stream.ts +++ b/app/soapbox/stream.ts @@ -8,10 +8,18 @@ import type { AppDispatch, RootState } from 'soapbox/store'; const randomIntUpTo = (max: number) => Math.floor(Math.random() * Math.floor(max)); +interface ConnectStreamCallbacks { + onConnect(): void + onDisconnect(): void + onReceive(websocket: WebSocket, data: unknown): void +} + +type PollingRefreshFn = (dispatch: AppDispatch, done?: () => void) => void + export function connectStream( path: string, - pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null, - callbacks: (dispatch: AppDispatch, getState: () => RootState) => Record = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} }), + pollingRefresh: PollingRefreshFn | null = null, + callbacks: (dispatch: AppDispatch, getState: () => RootState) => ConnectStreamCallbacks, ) { return (dispatch: AppDispatch, getState: () => RootState) => { const streamingAPIBaseURL = getState().instance.urls.get('streaming_api'); @@ -35,7 +43,7 @@ export function connectStream( } }; - let subscription: WebSocketClient; + let subscription: WebSocket; // If the WebSocket fails to be created, don't crash the whole page, // just proceed without a subscription. @@ -58,7 +66,7 @@ export function connectStream( }, received(data) { - onReceive(data); + onReceive(subscription, data); }, reconnected() { diff --git a/app/soapbox/types/nostr.ts b/app/soapbox/types/nostr.ts new file mode 100644 index 000000000..b395268ef --- /dev/null +++ b/app/soapbox/types/nostr.ts @@ -0,0 +1,8 @@ +import type { Event, EventTemplate } from 'nostr-tools'; + +interface Nostr { + getPublicKey(): Promise + signEvent(event: EventTemplate): Promise +} + +export default Nostr; \ No newline at end of file diff --git a/app/soapbox/types/window.d.ts b/app/soapbox/types/window.d.ts new file mode 100644 index 000000000..fc86542be --- /dev/null +++ b/app/soapbox/types/window.d.ts @@ -0,0 +1,7 @@ +import type Nostr from './nostr'; + +declare global { + interface Window { + nostr?: Nostr + } +} \ No newline at end of file diff --git a/package.json b/package.json index 50d1409c3..dab0ef4a1 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "localforage": "^1.10.0", "lodash": "^4.7.11", "mini-css-extract-plugin": "^2.6.0", + "nostr-tools": "^1.8.1", "path-browserify": "^1.0.1", "postcss": "^8.4.14", "postcss-loader": "^7.0.0", diff --git a/yarn.lock b/yarn.lock index 646742846..9b7feeeb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2483,6 +2483,28 @@ dependencies: eslint-scope "5.1.1" +"@noble/curves@~0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-0.8.3.tgz#ad6d48baf2599cf1d58dcb734c14d5225c8996e0" + integrity sha512-OqaOf4RWDaCRuBKJLDURrgVxjLmneGsiCXGuzYB5y95YithZMA6w4uk34DHSm0rKMrrYiaeZj48/81EvaAScLQ== + dependencies: + "@noble/hashes" "1.3.0" + +"@noble/hashes@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.0.0.tgz#d5e38bfbdaba174805a4e649f13be9a9ed3351ae" + integrity sha512-DZVbtY62kc3kkBtMHqwCOfXrT/hnoORy5BJ4+HU1IR59X0KWAOqsfzQPcUl/lQLlG7qXbe/fZ3r/emxtAl+sqg== + +"@noble/hashes@1.3.0", "@noble/hashes@~1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.0.tgz#085fd70f6d7d9d109671090ccae1d3bec62554a1" + integrity sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg== + +"@noble/secp256k1@^1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c" + integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2788,6 +2810,28 @@ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.2.1.tgz#812edd4104a15a493dda1ccac0b352270d7a188c" integrity sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ== +"@scure/base@^1.1.1", "@scure/base@~1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" + integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== + +"@scure/bip32@^1.1.5": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.2.0.tgz#35692d8f8cc3207200239fc119f9e038e5f465df" + integrity sha512-O+vT/hBVk+ag2i6j2CDemwd1E1MtGt+7O1KzrPNsaNvSsiEK55MyPIxJIMI2PS8Ijj464B2VbQlpRoQXxw1uHg== + dependencies: + "@noble/curves" "~0.8.3" + "@noble/hashes" "~1.3.0" + "@scure/base" "~1.1.0" + +"@scure/bip39@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.0.tgz#a207e2ef96de354de7d0002292ba1503538fc77b" + integrity sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg== + dependencies: + "@noble/hashes" "~1.3.0" + "@scure/base" "~1.1.0" + "@sentry/browser@7.37.2", "@sentry/browser@^7.37.2": version "7.37.2" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.37.2.tgz#355dd28ad12677d63e0b12c5209d12b3f98ac3a4" @@ -12934,6 +12978,18 @@ normalize-url@^6.0.1: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== +nostr-tools@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.8.1.tgz#4e54a354cc88ea0200634da3ee5a1c3466e1794c" + integrity sha512-/2IUe5xINUYT5hYBoEz51dfRaodbRHnyF8n+ZbKWCoh0ZRX6AL88OoDNrWaWWo7tP5j5OyzSL9g/z4TP7bshEA== + dependencies: + "@noble/hashes" "1.0.0" + "@noble/secp256k1" "^1.7.1" + "@scure/base" "^1.1.1" + "@scure/bip32" "^1.1.5" + "@scure/bip39" "^1.1.1" + prettier "^2.8.4" + npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -14112,6 +14168,11 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18" integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w== +prettier@^2.8.4: + version "2.8.7" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.7.tgz#bb79fc8729308549d28fe3a98fce73d2c0656450" + integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw== + pretty-error@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6" From c61368821a4b4d77916142256d6c8381a5479013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 2 May 2023 23:33:53 +0200 Subject: [PATCH 06/63] Use ListItem for 'Follow hashtag' setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/ui/column/column.tsx | 6 ++++-- .../features/hashtag-timeline/index.tsx | 21 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 8813b107b..8d7c2da39 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -51,6 +51,8 @@ export interface IColumn { withHeader?: boolean /** Extra class name for top
element. */ className?: string + /** Extra class name for the element. */ + bodyClassName?: string /** Ref forwarded to column. */ ref?: React.Ref /** Children to display in the column. */ @@ -63,7 +65,7 @@ export interface IColumn { /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { - const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props; + const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props; const soapboxConfig = useSoapboxConfig(); const [isScrolled, setIsScrolled] = useState(false); @@ -109,7 +111,7 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR /> )} - + {children} diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 960b699df..e448bef8a 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; -import { Column } from 'soapbox/components/ui'; +import List, { ListItem } from 'soapbox/components/list'; +import { Column, Toggle } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; @@ -19,8 +20,6 @@ const messages = defineMessages({ any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, - followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, - unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, }); @@ -119,7 +118,19 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + + {features.followHashtags && ( + + } + > + + + + )} Date: Tue, 2 May 2023 23:34:46 +0200 Subject: [PATCH 07/63] Follow hashtags: Support Akkoma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 04e5cc6e8..9fd16017d 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -491,7 +491,10 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/tags/:name/follow * @see POST /api/v1/tags/:name/unfollow */ - followHashtags: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + followHashtags: any([ + v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + v.software === PLEROMA && v.build === AKKOMA, + ]), /** * Ability to lock accounts and manually approve followers. From 610864d5a9b5fbbb858ce7f307c74ab594204adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 3 May 2023 00:21:53 +0200 Subject: [PATCH 08/63] Add followed tags list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/followed_tags/index.tsx | 52 ++++++++++++++++++++ app/soapbox/reducers/followed_tags.ts | 47 ++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 app/soapbox/features/followed_tags/index.tsx create mode 100644 app/soapbox/reducers/followed_tags.ts diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx new file mode 100644 index 000000000..6745f5fc0 --- /dev/null +++ b/app/soapbox/features/followed_tags/index.tsx @@ -0,0 +1,52 @@ +import debounce from 'lodash/debounce'; +import React, { useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags'; +import Hashtag from 'soapbox/components/hashtag'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; +import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandFollowedHashtags()); +}, 300, { leading: true }); + +const FollowedTags = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchFollowedHashtags()); + }, []); + + const tags = useAppSelector((state => state.followed_tags.items)); + const isLoading = useAppSelector((state => state.followed_tags.isLoading)); + const hasMore = useAppSelector((state => !!state.followed_tags.next)); + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + placeholderComponent={PlaceholderHashtag} + placeholderCount={5} + itemClassName='pb-3' + > + {tags.map(tag => )} + + + ); +}; + +export default FollowedTags; diff --git a/app/soapbox/reducers/followed_tags.ts b/app/soapbox/reducers/followed_tags.ts new file mode 100644 index 000000000..4f30a3f3a --- /dev/null +++ b/app/soapbox/reducers/followed_tags.ts @@ -0,0 +1,47 @@ +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; + +import { + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, +} from 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { APIEntity, Tag } from 'soapbox/types/entities'; + +const ReducerRecord = ImmutableRecord({ + items: ImmutableList(), + isLoading: false, + next: null, +}); + +export default function followed_tags(state = ReducerRecord(), action: AnyAction) { + switch (action.type) { + case FOLLOWED_HASHTAGS_FETCH_REQUEST: + return state.set('isLoading', true); + case FOLLOWED_HASHTAGS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', ImmutableList(action.followed_tags.map((item: APIEntity) => normalizeTag(item)))); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_FETCH_FAIL: + return state.set('isLoading', false); + case FOLLOWED_HASHTAGS_EXPAND_REQUEST: + return state.set('isLoading', true); + case FOLLOWED_HASHTAGS_EXPAND_SUCCESS: + return state.withMutations(map => { + map.update('items', list => list.concat(action.followed_tags.map((item: APIEntity) => normalizeTag(item)))); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_EXPAND_FAIL: + return state.set('isLoading', false); + default: + return state; + } +} From 586f536329e31e9a3bd8429f952bfa64184d4668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 3 May 2023 00:26:29 +0200 Subject: [PATCH 09/63] Update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 4 +--- app/soapbox/locales/en.json | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e5b2de2..179bee42b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Admin: redirect the homepage to any URL. -- Compatibility: added compatibility with Friendica. -- Hashtags: let users follow hashtags (Mastodon). +- Hashtags: let users follow hashtags (Mastodon, Akkoma). - Posts: Support posts filtering on recent Mastodon versions - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 7f2cfadd5..0576f8576 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -673,6 +673,7 @@ "empty_column.filters": "You haven't created any muted words yet.", "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.", "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", + "empty_column.followed_tags": "You haven't followed any hashtag yet.", "empty_column.group": "There are no posts in this group yet.", "empty_column.group_blocks": "The group hasn't banned any users yet.", "empty_column.group_membership_requests": "There are no pending membership requests for this group.", From afec0edc1ce4cf146105834b2bb2a55437230e14 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 8 May 2023 13:29:11 -0400 Subject: [PATCH 10/63] Add tests for Group API hooks --- .../hooks/groups/__tests__/useGroup.test.ts | 41 +++++++++++++++++ .../groups/__tests__/useGroupLookup.test.ts | 41 +++++++++++++++++ .../groups/__tests__/useGroupMedia.test.ts | 44 ++++++++++++++++++ .../groups/__tests__/useGroupMembers.test.ts | 45 +++++++++++++++++++ .../hooks/groups/__tests__/useGroups.test.ts | 43 ++++++++++++++++++ app/soapbox/jest/factory.ts | 8 ++++ 6 files changed, 222 insertions(+) create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts new file mode 100644 index 000000000..8afd06f1a --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts @@ -0,0 +1,41 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroup } from '../useGroup'; + +const group = buildGroup({ id: '1', display_name: 'soapbox' }); + +describe('useGroup hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroup(group.id)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.group?.id).toBe(group.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${group.id}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroup(group.id)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.group).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts new file mode 100644 index 000000000..2397b16ce --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts @@ -0,0 +1,41 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroupLookup } from '../useGroupLookup'; + +const group = buildGroup({ id: '1', slug: 'soapbox' }); + +describe('useGroupLookup hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupLookup(group.slug)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entity?.id).toBe(group.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupLookup(group.slug)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entity).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts new file mode 100644 index 000000000..a68b79eb1 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts @@ -0,0 +1,44 @@ +import { __stub } from 'soapbox/api'; +import { buildStatus } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroupMedia } from '../useGroupMedia'; + +const status = buildStatus(); +const groupId = '1'; + +describe('useGroupMedia hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupMedia(groupId)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entities.length).toBe(1); + expect(result.current.entities[0].id).toBe(status.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupMedia(groupId)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entities.length).toBe(0); + expect(result.current.isError).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts new file mode 100644 index 000000000..6f2fb6eac --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts @@ -0,0 +1,45 @@ +import { __stub } from 'soapbox/api'; +import { buildGroupMember } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { GroupRoles } from 'soapbox/schemas/group-member'; + +import { useGroupMembers } from '../useGroupMembers'; + +const groupMember = buildGroupMember(); +const groupId = '1'; + +describe('useGroupMembers hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groupMembers.length).toBe(1); + expect(result.current.groupMembers[0].id).toBe(groupMember.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groupMembers.length).toBe(0); + expect(result.current.isError).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts new file mode 100644 index 000000000..adff805f3 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts @@ -0,0 +1,43 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroups } from '../useGroups'; + +const group = buildGroup({ id: '1', display_name: 'soapbox' }); + +describe('useGroups hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups?q=').reply(200, [group]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroups()); + + console.log(result.current); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groups.length).toHaveLength(1); + }); + }); + + // describe('with an unsuccessful query', () => { + // beforeEach(() => { + // __stub((mock) => { + // mock.onGet('/api/v1/groups').networkError(); + // }); + // }); + + // it('is has error state', async() => { + // const { result } = renderHook(() => useGroups()); + + // await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // expect(result.current.groups).toHaveLength(0); + // }); + // }); +}); \ No newline at end of file diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index 35ea063e0..4d8ff336a 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -19,6 +19,7 @@ import { type Relationship, } from 'soapbox/schemas'; import { GroupRoles } from 'soapbox/schemas/group-member'; +import { statusSchema, type Status } from 'soapbox/schemas/status'; // TODO: there's probably a better way to create these factory functions. // This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock @@ -77,6 +78,12 @@ function buildRelationship(props: Partial = {}): Relationship { }, props)); } +function buildStatus(props: Partial = {}): Status { + return statusSchema.parse(Object.assign({ + id: uuidv4(), + }, props)); +} + export { buildAd, buildCard, @@ -85,4 +92,5 @@ export { buildGroupRelationship, buildGroupTag, buildRelationship, + buildStatus, }; \ No newline at end of file From 8d05747537851b9aad2e28a06dfaa0a79db76335 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 11 May 2023 08:30:13 -0400 Subject: [PATCH 11/63] Fix test --- .../hooks/groups/__tests__/useGroups.test.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts index adff805f3..739a1c0af 100644 --- a/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts +++ b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts @@ -1,43 +1,47 @@ import { __stub } from 'soapbox/api'; import { buildGroup } from 'soapbox/jest/factory'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeInstance } from 'soapbox/normalizers'; import { useGroups } from '../useGroups'; const group = buildGroup({ id: '1', display_name: 'soapbox' }); +const store = { + instance: normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', + }), +}; describe('useGroups hook', () => { describe('with a successful request', () => { beforeEach(() => { __stub((mock) => { - mock.onGet('/api/v1/groups?q=').reply(200, [group]); + mock.onGet('/api/v1/groups').reply(200, [group]); }); }); it('is successful', async () => { - const { result } = renderHook(() => useGroups()); - - console.log(result.current); + const { result } = renderHook(useGroups, undefined, store); await waitFor(() => expect(result.current.isFetching).toBe(false)); - expect(result.current.groups.length).toHaveLength(1); + expect(result.current.groups).toHaveLength(1); }); }); - // describe('with an unsuccessful query', () => { - // beforeEach(() => { - // __stub((mock) => { - // mock.onGet('/api/v1/groups').networkError(); - // }); - // }); + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups').networkError(); + }); + }); - // it('is has error state', async() => { - // const { result } = renderHook(() => useGroups()); + it('is has error state', async() => { + const { result } = renderHook(useGroups, undefined, store); - // await waitFor(() => expect(result.current.isFetching).toBe(false)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); - // expect(result.current.groups).toHaveLength(0); - // }); - // }); + expect(result.current.groups).toHaveLength(0); + }); + }); }); \ No newline at end of file From 6ab41eb8990ffeed49cffe2b65ec1c385aed2543 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 11 May 2023 14:20:33 -0400 Subject: [PATCH 12/63] Add ability to share Group --- .../group/components/group-options-button.tsx | 19 +++++++++++++++++++ app/soapbox/locales/en.json | 1 + 2 files changed, 20 insertions(+) diff --git a/app/soapbox/features/group/components/group-options-button.tsx b/app/soapbox/features/group/components/group-options-button.tsx index 597a751d7..ebc3152e4 100644 --- a/app/soapbox/features/group/components/group-options-button.tsx +++ b/app/soapbox/features/group/components/group-options-button.tsx @@ -19,6 +19,7 @@ const messages = defineMessages({ leave: { id: 'group.leave.label', defaultMessage: 'Leave' }, leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, report: { id: 'group.report.label', defaultMessage: 'Report' }, + share: { id: 'group.share.label', defaultMessage: 'Share' }, }); interface IGroupActionButton { @@ -35,6 +36,15 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isBlocked = group.relationship?.blocked_by; + const handleShare = () => { + navigator.share({ + text: group.display_name, + url: group.url, + }).catch((e) => { + if (e.name !== 'AbortError') console.error(e); + }); + }; + const onLeaveGroup = () => dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.confirmationHeading), @@ -49,6 +59,7 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { })); const menu: Menu = useMemo(() => { + const canShare = 'share' in navigator; const items = []; if (isMember || isAdmin) { @@ -59,6 +70,14 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { }); } + if (canShare) { + items.push({ + text: intl.formatMessage(messages.share), + icon: require('@tabler/icons/share.svg'), + action: handleShare, + }); + } + if (isAdmin) { items.push({ text: intl.formatMessage(messages.leave), diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 6138dbdd5..9281992cb 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -807,6 +807,7 @@ "group.report.label": "Report", "group.role.admin": "Admin", "group.role.owner": "Owner", + "group.share.label": "Share", "group.tabs.all": "All", "group.tabs.media": "Media", "group.tabs.members": "Members", From 93c67c863ab2bc706afbe77f09f60e913327fe92 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 11 May 2023 14:28:44 -0400 Subject: [PATCH 13/63] Format tag input with # --- .../features/group/components/group-tags-field.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/group/components/group-tags-field.tsx b/app/soapbox/features/group/components/group-tags-field.tsx index ba98d808e..f8092d5c0 100644 --- a/app/soapbox/features/group/components/group-tags-field.tsx +++ b/app/soapbox/features/group/components/group-tags-field.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { Input, Streamfield } from 'soapbox/components/ui'; @@ -36,15 +36,19 @@ const GroupTagsField: React.FC = ({ tags, onChange, onAddItem, const HashtagField: StreamfieldComponent = ({ value, onChange, autoFocus = false }) => { const intl = useIntl(); + const formattedValue = useMemo(() => { + return `#${value}`; + }, [value]); + const handleChange: React.ChangeEventHandler = ({ target }) => { - onChange(target.value); + onChange(target.value.replace('#', '')); }; return ( Date: Thu, 11 May 2023 14:41:31 -0400 Subject: [PATCH 14/63] Hide Group context in Compose button if not Group member --- app/soapbox/api/hooks/groups/useGroupLookup.ts | 11 ++++++++++- app/soapbox/api/hooks/groups/useGroupRelationship.ts | 9 ++++++--- app/soapbox/entity-store/hooks/useEntity.ts | 6 +++++- app/soapbox/features/ui/components/compose-button.tsx | 6 +++++- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts index 89c778a15..6e41975e5 100644 --- a/app/soapbox/api/hooks/groups/useGroupLookup.ts +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -3,15 +3,24 @@ import { useEntityLookup } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { groupSchema } from 'soapbox/schemas'; +import { useGroupRelationship } from './useGroupRelationship'; + function useGroupLookup(slug: string) { const api = useApi(); - return useEntityLookup( + const { entity: group, ...result } = useEntityLookup( Entities.GROUPS, (group) => group.slug === slug, () => api.get(`/api/v1/groups/lookup?name=${slug}`), { schema: groupSchema }, ); + + const { entity: relationship } = useGroupRelationship(group?.id); + + return { + ...result, + entity: group ? { ...group, relationship: relationship || null } : undefined, + }; } export { useGroupLookup }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupRelationship.ts b/app/soapbox/api/hooks/groups/useGroupRelationship.ts index 6b24c463c..21d8d3efd 100644 --- a/app/soapbox/api/hooks/groups/useGroupRelationship.ts +++ b/app/soapbox/api/hooks/groups/useGroupRelationship.ts @@ -7,14 +7,17 @@ import { useEntity } from 'soapbox/entity-store/hooks'; import { useApi, useAppDispatch } from 'soapbox/hooks'; import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas'; -function useGroupRelationship(groupId: string) { +function useGroupRelationship(groupId: string | undefined) { const api = useApi(); const dispatch = useAppDispatch(); const { entity: groupRelationship, ...result } = useEntity( - [Entities.GROUP_RELATIONSHIPS, groupId], + [Entities.GROUP_RELATIONSHIPS, groupId as string], () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), - { schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) }, + { + enabled: !!groupId, + schema: z.array(groupRelationshipSchema).transform(arr => arr[0]), + }, ); useEffect(() => { diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 63447ae67..3d57c8ab0 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -14,6 +14,8 @@ interface UseEntityOpts { schema?: EntitySchema /** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */ refetch?: boolean + /** A flag to potentially disable sending requests to the API. */ + enabled?: boolean } function useEntity( @@ -31,6 +33,7 @@ function useEntity( const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined); + const isEnabled = opts.enabled ?? true; const isLoading = isFetching && !entity; const fetchEntity = async () => { @@ -44,10 +47,11 @@ function useEntity( }; useEffect(() => { + if (!isEnabled) return; if (!entity || opts.refetch) { fetchEntity(); } - }, []); + }, [isEnabled]); return { entity, diff --git a/app/soapbox/features/ui/components/compose-button.tsx b/app/soapbox/features/ui/components/compose-button.tsx index a21909bb1..7686f9c55 100644 --- a/app/soapbox/features/ui/components/compose-button.tsx +++ b/app/soapbox/features/ui/components/compose-button.tsx @@ -10,8 +10,12 @@ import { useAppDispatch } from 'soapbox/hooks'; const ComposeButton = () => { const location = useLocation(); + const isOnGroupPage = location.pathname.startsWith('/group/'); + const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug'); + const { entity: group } = useGroupLookup(match?.params.groupSlug || ''); + const isGroupMember = !!group?.relationship?.member; - if (location.pathname.startsWith('/group/')) { + if (isOnGroupPage && isGroupMember) { return ; } From 2c59933cd028ea045f9a5211b8be1d7f2eacde5f Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 11 May 2023 15:00:28 -0400 Subject: [PATCH 15/63] Handle API errors when failing to join group --- .../features/group/components/group-action-button.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx index 005b9e245..f3b208574 100644 --- a/app/soapbox/features/group/components/group-action-button.tsx +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -55,6 +55,12 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { : intl.formatMessage(messages.joinSuccess), ); }, + onError(error) { + const message = (error.response?.data as any).error; + if (message) { + toast.error(message); + } + }, }); const onLeaveGroup = () => From e84a4a6c45f3c6e34f6448bb87c3074ebccc3db6 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 11 May 2023 15:13:07 -0400 Subject: [PATCH 16/63] Fix full-width of Group Header --- app/soapbox/features/group/components/group-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index ad22e1e13..4cfe8f786 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -99,7 +99,7 @@ const GroupHeader: React.FC = ({ group }) => { if (!isDefaultHeader(group.header)) { header = ( - + {header} ); From d2eca144fa50c0ca6f5a21642f2319f5dc3fe3d1 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 11 May 2023 15:13:30 -0400 Subject: [PATCH 17/63] Make sure GroupLinkPreview covers entire header --- app/soapbox/features/groups/components/group-link-preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/groups/components/group-link-preview.tsx b/app/soapbox/features/groups/components/group-link-preview.tsx index 18ca586a5..98ca03076 100644 --- a/app/soapbox/features/groups/components/group-link-preview.tsx +++ b/app/soapbox/features/groups/components/group-link-preview.tsx @@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC = ({ card }) => { return (
From 41e969616dad0cf22e2214fd289ef1c279706fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 11 May 2023 20:10:22 +0200 Subject: [PATCH 18/63] Forgot to commit some files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/tags.ts | 83 ++++++++++++++++++++++++++++++++++- app/soapbox/locales/en.json | 1 - app/soapbox/reducers/index.ts | 2 + 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts index 2c394ba46..75d8e00fa 100644 --- a/app/soapbox/actions/tags.ts +++ b/app/soapbox/actions/tags.ts @@ -1,4 +1,4 @@ -import api from '../api'; +import api, { getLinks } from '../api'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -16,6 +16,14 @@ const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; +const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; +const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; +const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; + +const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; +const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; +const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; + const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(fetchHashtagRequest()); @@ -95,6 +103,65 @@ const unfollowHashtagFail = (name: string, error: AxiosError) => ({ error, }); +const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowedHashtagsRequest()); + + api(getState).get('/api/v1/followed_tags').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchFollowedHashtagsFail(err)); + }); +}; + +const fetchFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_FETCH_REQUEST, +}); + +const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, + followed_tags, + next, +}); + +const fetchFollowedHashtagsFail = (error: AxiosError) => ({ + type: FOLLOWED_HASHTAGS_FETCH_FAIL, + error, +}); + +const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().followed_tags.next; + + if (url === null) { + return; + } + + dispatch(expandFollowedHashtagsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFollowedHashtagsFail(error)); + }); +}; + +const expandFollowedHashtagsRequest = () => ({ + type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, +}); + +const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + followed_tags, + next, +}); + +const expandFollowedHashtagsFail = (error: AxiosError) => ({ + type: FOLLOWED_HASHTAGS_EXPAND_FAIL, + error, +}); + + export { HASHTAG_FETCH_REQUEST, HASHTAG_FETCH_SUCCESS, @@ -105,6 +172,12 @@ export { HASHTAG_UNFOLLOW_REQUEST, HASHTAG_UNFOLLOW_SUCCESS, HASHTAG_UNFOLLOW_FAIL, + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, fetchHashtag, fetchHashtagRequest, fetchHashtagSuccess, @@ -117,4 +190,12 @@ export { unfollowHashtagRequest, unfollowHashtagSuccess, unfollowHashtagFail, + fetchFollowedHashtags, + fetchFollowedHashtagsRequest, + fetchFollowedHashtagsSuccess, + fetchFollowedHashtagsFail, + expandFollowedHashtags, + expandFollowedHashtagsRequest, + expandFollowedHashtagsSuccess, + expandFollowedHashtagsFail, }; diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 11badebe2..919b33901 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -671,7 +671,6 @@ "empty_column.filters": "You haven't created any muted words yet.", "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.", "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", - "empty_column.followed_tags": "You haven't followed any hashtag yet.", "empty_column.group": "There are no posts in this group yet.", "empty_column.group_blocks": "The group hasn't banned any users yet.", "empty_column.group_membership_requests": "There are no pending membership requests for this group.", diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index b02ae8117..bde340b60 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -28,6 +28,7 @@ import custom_emojis from './custom-emojis'; import domain_lists from './domain-lists'; import dropdown_menu from './dropdown-menu'; import filters from './filters'; +import followed_tags from './followed_tags'; import group_memberships from './group-memberships'; import group_relationships from './group-relationships'; import groups from './groups'; @@ -93,6 +94,7 @@ const reducers = { dropdown_menu, entities, filters, + followed_tags, group_memberships, group_relationships, groups, From f27933965ff63351f08427a219ba5b29dfacc121 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Fri, 12 May 2023 11:51:00 -0400 Subject: [PATCH 19/63] Allow non-owners to see pinned tags --- app/soapbox/components/ui/icon/icon.tsx | 7 ++- .../__tests__/group-tag-list-item.test.tsx | 43 ++++++++++--------- .../group/components/group-tag-list-item.tsx | 30 +++++++++---- app/soapbox/features/group/group-tags.tsx | 2 +- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/app/soapbox/components/ui/icon/icon.tsx b/app/soapbox/components/ui/icon/icon.tsx index f51c3ca38..709b3126f 100644 --- a/app/soapbox/components/ui/icon/icon.tsx +++ b/app/soapbox/components/ui/icon/icon.tsx @@ -17,11 +17,16 @@ interface IIcon extends Pick, 'strokeWidth'> { src: string /** Width and height of the icon in pixels. */ size?: number + /** Override the data-testid */ + 'data-testid'?: string } /** Renders and SVG icon with optional counter. */ const Icon: React.FC = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => ( -
+
{count ? ( diff --git a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx index 4418fff86..f91853dc4 100644 --- a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx @@ -91,31 +91,32 @@ describe('', () => { expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); }); }); + }); - describe('as a non-owner', () => { - const group = buildGroup({ - relationship: buildGroupRelationship({ - role: GroupRoles.ADMIN, - member: true, - }), + describe('as a non-owner', () => { + const group = buildGroup({ + relationship: buildGroupRelationship({ + role: GroupRoles.ADMIN, + member: true, + }), + }); + + describe('when the tag is pinned', () => { + const tag = buildGroupTag({ pinned: true, visible: true }); + + it('does render the pin icon', () => { + render(); + screen.debug(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(1); }); + }); - describe('when the tag is visible', () => { - const tag = buildGroupTag({ visible: true }); + describe('when the tag is not pinned', () => { + const tag = buildGroupTag({ pinned: false, visible: true }); - it('does not render the pin icon', () => { - render(); - expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); - }); - }); - - describe('when the tag is not visible', () => { - const tag = buildGroupTag({ visible: false }); - - it('does not render the pin icon', () => { - render(); - expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); - }); + it('does not render the pin icon', () => { + render(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); }); }); }); diff --git a/app/soapbox/features/group/components/group-tag-list-item.tsx b/app/soapbox/features/group/components/group-tag-list-item.tsx index bf02cc202..07660cf21 100644 --- a/app/soapbox/features/group/components/group-tag-list-item.tsx +++ b/app/soapbox/features/group/components/group-tag-list-item.tsx @@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { useUpdateGroupTag } from 'soapbox/api/hooks'; -import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; +import { HStack, Icon, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; import { useAppDispatch } from 'soapbox/hooks'; @@ -84,6 +84,20 @@ const GroupTagListItem = (props: IGroupMemberListItem) => { }; const renderPinIcon = () => { + if (!isOwner && tag.pinned) { + return ( + + ); + } + + if (!isOwner) { + return null; + } + if (isPinnable) { return ( { - {isOwner ? ( - - {tag.visible ? ( - renderPinIcon() - ) : null} + + {tag.visible ? ( + renderPinIcon() + ) : null} + {isOwner ? ( { iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' /> - - ) : null} + ) : null} + ); }; diff --git a/app/soapbox/features/group/group-tags.tsx b/app/soapbox/features/group/group-tags.tsx index 710a4fdb5..d5335e844 100644 --- a/app/soapbox/features/group/group-tags.tsx +++ b/app/soapbox/features/group/group-tags.tsx @@ -36,7 +36,7 @@ const GroupTopics: React.FC = (props) => { showLoading={!group || isLoading && tags.length === 0} placeholderComponent={PlaceholderAccount} placeholderCount={3} - className='divide-y divide-solid divide-gray-300' + className='divide-y divide-solid divide-gray-300 dark:divide-gray-800' itemClassName='py-3 last:pb-0' emptyMessage={ From 682a86049e8113be423044b62f86e4615d4c21cf Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Fri, 12 May 2023 11:51:12 -0400 Subject: [PATCH 20/63] Linkify urls inside Group note --- app/soapbox/features/group/components/group-header.tsx | 1 + .../modals/manage-group-modal/steps/confirmation-step.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 4cfe8f786..2491a7bb7 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -155,6 +155,7 @@ const GroupHeader: React.FC = ({ group }) => { theme='muted' align='center' dangerouslySetInnerHTML={{ __html: group.note_emojified }} + className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue' /> diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx index 11244c3c5..785da5da1 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx @@ -56,7 +56,7 @@ const ConfirmationStep: React.FC = ({ group }) => { {group.display_name} From cfaa3ea6698cee09912cc4b2a6b67d9a78940bad Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Fri, 12 May 2023 12:50:32 -0400 Subject: [PATCH 21/63] Invalidate group tags after updating Group --- app/soapbox/features/group/edit-group.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/group/edit-group.tsx b/app/soapbox/features/group/edit-group.tsx index c0e972604..82f4841b4 100644 --- a/app/soapbox/features/group/edit-group.tsx +++ b/app/soapbox/features/group/edit-group.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { useGroup, useUpdateGroup } from 'soapbox/api/hooks'; +import { useGroup, useGroupTags, useUpdateGroup } from 'soapbox/api/hooks'; import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui'; import { useAppSelector, useInstance } from 'soapbox/hooks'; import { useImageField, useTextField } from 'soapbox/hooks/forms'; @@ -36,6 +36,7 @@ const EditGroup: React.FC = ({ params: { groupId } }) => { const { group, isLoading } = useGroup(groupId); const { updateGroup } = useUpdateGroup(groupId); + const { invalidate } = useGroupTags(groupId); const [isSubmitting, setIsSubmitting] = useState(false); const [tags, setTags] = useState(['']); @@ -64,6 +65,7 @@ const EditGroup: React.FC = ({ params: { groupId } }) => { tags, }, { onSuccess() { + invalidate(); toast.success(intl.formatMessage(messages.groupSaved)); }, onError(error) { From 275d456693978e0ec6f2949f2dca4ccf6d474fb5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 12 May 2023 20:50:30 -0500 Subject: [PATCH 22/63] Fix groups test --- app/soapbox/jest/factory.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index 4d8ff336a..403904209 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; +import { normalizeStatus } from 'soapbox/normalizers'; import { accountSchema, adSchema, @@ -17,9 +18,9 @@ import { type GroupRelationship, type GroupTag, type Relationship, + type Status, } from 'soapbox/schemas'; import { GroupRoles } from 'soapbox/schemas/group-member'; -import { statusSchema, type Status } from 'soapbox/schemas/status'; // TODO: there's probably a better way to create these factory functions. // This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock @@ -79,7 +80,7 @@ function buildRelationship(props: Partial = {}): Relationship { } function buildStatus(props: Partial = {}): Status { - return statusSchema.parse(Object.assign({ + return normalizeStatus(Object.assign({ id: uuidv4(), }, props)); } From 829d207cead6a8002d8362f5345192e74ef15bd0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 12 May 2023 21:46:36 -0500 Subject: [PATCH 23/63] Fix type --- app/soapbox/jest/factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index 403904209..019b0b17f 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -79,7 +79,7 @@ function buildRelationship(props: Partial = {}): Relationship { }, props)); } -function buildStatus(props: Partial = {}): Status { +function buildStatus(props: Partial = {}) { return normalizeStatus(Object.assign({ id: uuidv4(), }, props)); From 85f526d1d492a05cb42a05a0f13e1a28596795b1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 13 May 2023 21:07:36 -0500 Subject: [PATCH 24/63] Sign Nostr event with ditto --- app/soapbox/actions/streaming.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index c96de40e3..b42ebfd78 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -181,12 +181,12 @@ const connectTimelineStream = ( case 'marker': dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); break; - case 'nostr:signEvent': + case 'nostr.sign': (async () => { const event = await window.nostr?.signEvent(JSON.parse(data.payload)); if (event) { - websocket.send(JSON.stringify({ event: 'nostr:event', payload: event })); + websocket.send(JSON.stringify({ event: 'nostr.sign', payload: JSON.stringify(event) })); } })(); break; @@ -201,7 +201,7 @@ const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => dispatch(fetchAnnouncements(done)))))); const connectUserStream = (opts?: StreamOpts) => - connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification, null, opts); + connectTimelineStream('home', `user${'nostr' in window ? '&nostr=true' : ''}`, refreshHomeTimelineAndNotification, null, opts); const connectCommunityStream = ({ onlyMedia }: Record = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); From e5837ebefbd7bf9bfb070e2000bf4b93df07e422 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 13 May 2023 21:25:59 -0500 Subject: [PATCH 25/63] nip07: catch signEvent error --- app/soapbox/actions/streaming.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index b42ebfd78..a0dddda83 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -183,7 +183,7 @@ const connectTimelineStream = ( break; case 'nostr.sign': (async () => { - const event = await window.nostr?.signEvent(JSON.parse(data.payload)); + const event = await window.nostr?.signEvent(JSON.parse(data.payload)).catch(() => undefined); if (event) { websocket.send(JSON.stringify({ event: 'nostr.sign', payload: JSON.stringify(event) })); From 9c5acc09f0d235d3f4e2092341c6632a850ce96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 14 May 2023 22:35:53 +0200 Subject: [PATCH 26/63] Slightly reorder account header menu items, add 'Copy link to profile' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../features/account/components/header.tsx | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index fbdfc3181..adddce9e2 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -30,6 +30,7 @@ import { queryClient } from 'soapbox/queries/client'; import toast from 'soapbox/toast'; import { Account } from 'soapbox/types/entities'; import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts'; +import copy from 'soapbox/utils/copy'; import { MASTODON, parseVersion } from 'soapbox/utils/features'; const messages = defineMessages({ @@ -44,6 +45,7 @@ const messages = defineMessages({ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + copy: { id: 'status.copy', defaultMessage: 'Copy link to profile' }, share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, @@ -273,6 +275,10 @@ const Header: React.FC = ({ account }) => { }); }; + const handleCopy: React.EventHandler = (e) => { + copy(account.url); + }; + const makeMenu = () => { const menu: Menu = []; @@ -306,8 +312,22 @@ const Header: React.FC = ({ account }) => { }); } + menu.push({ + text: intl.formatMessage(messages.copy), + action: handleCopy, + icon: require('@tabler/icons/clipboard-copy.svg'), + }); + if (!ownAccount) return menu; + if (features.searchFromAccount) { + menu.push({ + text: intl.formatMessage(account.id === ownAccount.id ? messages.searchSelf : messages.search, { name: account.username }), + action: onSearch, + icon: require('@tabler/icons/search.svg'), + }); + } + if (menu.length) { menu.push(null); } @@ -323,13 +343,6 @@ const Header: React.FC = ({ account }) => { to: '/settings', icon: require('@tabler/icons/settings.svg'), }); - if (features.searchFromAccount) { - menu.push({ - text: intl.formatMessage(messages.searchSelf, { name: account.username }), - action: onSearch, - icon: require('@tabler/icons/search.svg'), - }); - } menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), @@ -386,8 +399,6 @@ const Header: React.FC = ({ account }) => { icon: require('@tabler/icons/user-check.svg'), }); } - - menu.push(null); } else if (features.lists && features.unrestrictedLists) { menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), @@ -396,13 +407,7 @@ const Header: React.FC = ({ account }) => { }); } - if (features.searchFromAccount) { - menu.push({ - text: intl.formatMessage(messages.search, { name: account.username }), - action: onSearch, - icon: require('@tabler/icons/search.svg'), - }); - } + menu.push(null); if (features.removeFromFollowers && account.relationship?.followed_by) { menu.push({ From f4da2006ef58584f1e01dd409316fd65757533f1 Mon Sep 17 00:00:00 2001 From: oakes Date: Thu, 18 May 2023 11:17:55 -0400 Subject: [PATCH 27/63] Pass baseURL when revoking oauth token --- app/soapbox/actions/oauth.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/soapbox/actions/oauth.ts b/app/soapbox/actions/oauth.ts index 55df6f1ae..1dc318648 100644 --- a/app/soapbox/actions/oauth.ts +++ b/app/soapbox/actions/oauth.ts @@ -6,6 +6,8 @@ * @see module:soapbox/actions/auth */ +import { getBaseURL } from 'soapbox/utils/state'; + import { baseClient } from '../api'; import type { AppDispatch } from 'soapbox/store'; @@ -31,9 +33,10 @@ export const obtainOAuthToken = (params: Record, bas }; export const revokeOAuthToken = (params: Record) => - (dispatch: AppDispatch) => { + (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params }); - return baseClient().post('/oauth/revoke', params).then(({ data }) => { + const baseURL = getBaseURL(getState()); + return baseClient(null, baseURL).post('/oauth/revoke', params).then(({ data }) => { dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data }); return data; }).catch(error => { From 98f5450a9e044fa63a7cc0032f7cdf648d061c0f Mon Sep 17 00:00:00 2001 From: oakes Date: Thu, 18 May 2023 12:53:32 -0400 Subject: [PATCH 28/63] Add missing import --- app/soapbox/actions/oauth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/actions/oauth.ts b/app/soapbox/actions/oauth.ts index 1dc318648..1c3c8a748 100644 --- a/app/soapbox/actions/oauth.ts +++ b/app/soapbox/actions/oauth.ts @@ -10,7 +10,7 @@ import { getBaseURL } from 'soapbox/utils/state'; import { baseClient } from '../api'; -import type { AppDispatch } from 'soapbox/store'; +import type { AppDispatch, RootState } from 'soapbox/store'; export const OAUTH_TOKEN_CREATE_REQUEST = 'OAUTH_TOKEN_CREATE_REQUEST'; export const OAUTH_TOKEN_CREATE_SUCCESS = 'OAUTH_TOKEN_CREATE_SUCCESS'; From 7efc5e2fbde87ada7d3c7e7b61d776f5697dad57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 20 May 2023 00:22:10 +0200 Subject: [PATCH 29/63] Add '/' as a hotkey for search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/ui/components/modals/hotkeys-modal.tsx | 2 +- app/soapbox/features/ui/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/ui/components/modals/hotkeys-modal.tsx b/app/soapbox/features/ui/components/modals/hotkeys-modal.tsx index 595254a98..48608047b 100644 --- a/app/soapbox/features/ui/components/modals/hotkeys-modal.tsx +++ b/app/soapbox/features/ui/components/modals/hotkeys-modal.tsx @@ -113,7 +113,7 @@ const HotkeysModal: React.FC = ({ onClose }) => { - s + s, / diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 298e3314c..d09e07e80 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -156,7 +156,7 @@ const EmptyPage = HomePage; const keyMap = { help: '?', new: 'n', - search: 's', + search: ['s', '/'], forceNew: 'option+n', reply: 'r', favourite: 'f', From 2709153c2042050326d585dccf82f60afa9f4099 Mon Sep 17 00:00:00 2001 From: NEETzsche Date: Fri, 19 May 2023 16:18:26 -0700 Subject: [PATCH 30/63] api/pleroma/admin --> api/v1/pleroma/admin --- app/soapbox/actions/admin.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 52be03ac2..adff5ae50 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -127,7 +127,7 @@ const fetchConfig = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); return api(getState) - .get('/api/pleroma/admin/config') + .get('/api/v1/pleroma/admin/config') .then(({ data }) => { dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); }).catch(error => { @@ -139,7 +139,7 @@ const updateConfig = (configs: Record[]) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs }); return api(getState) - .post('/api/pleroma/admin/config', { configs }) + .post('/api/v1/pleroma/admin/config', { configs }) .then(({ data }) => { dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); }).catch(error => { @@ -178,7 +178,7 @@ const fetchMastodonReports = (params: Record) => const fetchPleromaReports = (params: Record) => (dispatch: AppDispatch, getState: () => RootState) => api(getState) - .get('/api/pleroma/admin/reports', { params }) + .get('/api/v1/pleroma/admin/reports', { params }) .then(({ data: { reports } }) => { reports.forEach((report: APIEntity) => { dispatch(importFetchedAccount(report.account)); @@ -224,7 +224,7 @@ const patchMastodonReports = (reports: { id: string, state: string }[]) => const patchPleromaReports = (reports: { id: string, state: string }[]) => (dispatch: AppDispatch, getState: () => RootState) => api(getState) - .patch('/api/pleroma/admin/reports', { reports }) + .patch('/api/v1/pleroma/admin/reports', { reports }) .then(() => { dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports }); }).catch(error => { @@ -286,7 +286,7 @@ const fetchPleromaUsers = (filters: string[], page: number, query?: string | nul if (query) params.query = query; return api(getState) - .get('/api/pleroma/admin/users', { params }) + .get('/api/v1/pleroma/admin/users', { params }) .then(({ data: { users, count, page_size: pageSize } }) => { dispatch(fetchRelationships(users.map((user: APIEntity) => user.id))); dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users, count, pageSize, filters, page }); @@ -331,7 +331,7 @@ const deactivatePleromaUsers = (accountIds: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); return api(getState) - .patch('/api/pleroma/admin/users/deactivate', { nicknames }) + .patch('/api/v1/pleroma/admin/users/deactivate', { nicknames }) .then(({ data: { users } }) => { dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, users, accountIds }); }).catch(error => { @@ -360,7 +360,7 @@ const deleteUsers = (accountIds: string[]) => const nicknames = nicknamesFromIds(getState, accountIds); dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds }); return api(getState) - .delete('/api/pleroma/admin/users', { data: { nicknames } }) + .delete('/api/v1/pleroma/admin/users', { data: { nicknames } }) .then(({ data: nicknames }) => { dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds }); }).catch(error => { @@ -384,7 +384,7 @@ const approvePleromaUsers = (accountIds: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); return api(getState) - .patch('/api/pleroma/admin/users/approve', { nicknames }) + .patch('/api/v1/pleroma/admin/users/approve', { nicknames }) .then(({ data: { users } }) => { dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds }); }).catch(error => { @@ -412,7 +412,7 @@ const deleteStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id }); return api(getState) - .delete(`/api/pleroma/admin/statuses/${id}`) + .delete(`/api/v1/pleroma/admin/statuses/${id}`) .then(() => { dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id }); }).catch(error => { @@ -424,7 +424,7 @@ const toggleStatusSensitivity = (id: string, sensitive: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, id }); return api(getState) - .put(`/api/pleroma/admin/statuses/${id}`, { sensitive: !sensitive }) + .put(`/api/v1/pleroma/admin/statuses/${id}`, { sensitive: !sensitive }) .then(() => { dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, id }); }).catch(error => { @@ -436,7 +436,7 @@ const fetchModerationLog = (params?: Record) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_LOG_FETCH_REQUEST }); return api(getState) - .get('/api/pleroma/admin/moderation_log', { params }) + .get('/api/v1/pleroma/admin/moderation_log', { params }) .then(({ data }) => { dispatch({ type: ADMIN_LOG_FETCH_SUCCESS, items: data.items, total: data.total }); return data; @@ -567,7 +567,7 @@ const suggestUsers = (accountIds: string[]) => const nicknames = nicknamesFromIds(getState, accountIds); dispatch({ type: ADMIN_USERS_SUGGEST_REQUEST, accountIds }); return api(getState) - .patch('/api/pleroma/admin/users/suggest', { nicknames }) + .patch('/api/v1/pleroma/admin/users/suggest', { nicknames }) .then(({ data: { users } }) => { dispatch({ type: ADMIN_USERS_SUGGEST_SUCCESS, users, accountIds }); }).catch(error => { @@ -580,7 +580,7 @@ const unsuggestUsers = (accountIds: string[]) => const nicknames = nicknamesFromIds(getState, accountIds); dispatch({ type: ADMIN_USERS_UNSUGGEST_REQUEST, accountIds }); return api(getState) - .patch('/api/pleroma/admin/users/unsuggest', { nicknames }) + .patch('/api/v1/pleroma/admin/users/unsuggest', { nicknames }) .then(({ data: { users } }) => { dispatch({ type: ADMIN_USERS_UNSUGGEST_SUCCESS, users, accountIds }); }).catch(error => { @@ -636,7 +636,7 @@ const fetchAdminAnnouncements = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST }); return api(getState) - .get('/api/pleroma/admin/announcements', { params: { limit: 50 } }) + .get('/api/v1/pleroma/admin/announcements', { params: { limit: 50 } }) .then(({ data }) => { dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data }); return data; @@ -651,7 +651,7 @@ const expandAdminAnnouncements = () => dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST }); return api(getState) - .get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } }) + .get('/api/v1/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } }) .then(({ data }) => { dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data }); return data; @@ -687,7 +687,7 @@ const handleCreateAnnouncement = () => const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form; return api(getState)[id ? 'patch' : 'post']( - id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements', + id ? `/api/v1/pleroma/admin/announcements/${id}` : '/api/v1/pleroma/admin/announcements', { content, starts_at, ends_at, all_day }, ).then(({ data }) => { dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data }); @@ -703,7 +703,7 @@ const deleteAnnouncement = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id }); - return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => { + return api(getState).delete(`/api/v1/pleroma/admin/announcements/${id}`).then(({ data }) => { dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id }); toast.success(messages.announcementDeleteSuccess); dispatch(fetchAdminAnnouncements()); From f318c35544487654022605f575c48d0cd3822b53 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 20 May 2023 21:15:53 -0500 Subject: [PATCH 31/63] Simplify event signing --- app/soapbox/actions/streaming.ts | 16 ++++++++-------- app/soapbox/features/ui/index.tsx | 24 +++++++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index a0dddda83..65752c223 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -182,13 +182,9 @@ const connectTimelineStream = ( dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); break; case 'nostr.sign': - (async () => { - const event = await window.nostr?.signEvent(JSON.parse(data.payload)).catch(() => undefined); - - if (event) { - websocket.send(JSON.stringify({ event: 'nostr.sign', payload: JSON.stringify(event) })); - } - })(); + window.nostr?.signEvent(JSON.parse(data.payload)) + .then((data) => websocket.send(JSON.stringify({ type: 'nostr.sign', data }))) + .catch(() => console.warn('Failed to sign Nostr event.')); break; } }, @@ -201,7 +197,7 @@ const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => dispatch(fetchAnnouncements(done)))))); const connectUserStream = (opts?: StreamOpts) => - connectTimelineStream('home', `user${'nostr' in window ? '&nostr=true' : ''}`, refreshHomeTimelineAndNotification, null, opts); + connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification, null, opts); const connectCommunityStream = ({ onlyMedia }: Record = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); @@ -224,6 +220,9 @@ const connectListStream = (id: string) => const connectGroupStream = (id: string) => connectTimelineStream(`group:${id}`, `group&group=${id}`); +const connectNostrStream = () => + connectTimelineStream('nostr', 'nostr'); + export { STREAMING_CHAT_UPDATE, STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, @@ -236,4 +235,5 @@ export { connectDirectStream, connectListStream, connectGroupStream, + connectNostrStream, }; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 298e3314c..4d487dfea 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -16,7 +16,7 @@ import { openModal } from 'soapbox/actions/modals'; import { expandNotifications } from 'soapbox/actions/notifications'; import { register as registerPushNotifications } from 'soapbox/actions/push-notifications'; import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses'; -import { connectUserStream } from 'soapbox/actions/streaming'; +import { connectNostrStream, connectUserStream } from 'soapbox/actions/streaming'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc'; @@ -391,7 +391,8 @@ const UI: React.FC = ({ children }) => { const instance = useInstance(); const statContext = useStatContext(); - const disconnect = useRef(null); + const userStream = useRef(null); + const nostrStream = useRef(null); const node = useRef(null); const hotkeys = useRef(null); @@ -416,15 +417,24 @@ const UI: React.FC = ({ children }) => { }; const connectStreaming = () => { - if (!disconnect.current && accessToken && streamingUrl) { - disconnect.current = dispatch(connectUserStream({ statContext })); + if (accessToken && streamingUrl) { + if (!userStream.current) { + userStream.current = dispatch(connectUserStream({ statContext })); + } + if (!nostrStream.current && window.nostr) { + nostrStream.current = dispatch(connectNostrStream()); + } } }; const disconnectStreaming = () => { - if (disconnect.current) { - disconnect.current(); - disconnect.current = null; + if (userStream.current) { + userStream.current(); + userStream.current = null; + } + if (nostrStream.current) { + nostrStream.current(); + nostrStream.current = null; } }; From 2605f72cb81562562fe041091ff9c93e5818107b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 21 May 2023 23:32:46 +0200 Subject: [PATCH 32/63] Change key for 'copy link to profile' message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/account/components/header.tsx | 2 +- app/soapbox/locales/en.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index adddce9e2..81743c2ac 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -45,7 +45,7 @@ const messages = defineMessages({ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' }, - copy: { id: 'status.copy', defaultMessage: 'Copy link to profile' }, + copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' }, share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 879294583..d46cf4ebf 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -10,6 +10,7 @@ "account.block_domain": "Hide everything from {domain}", "account.blocked": "Blocked", "account.chat": "Chat with @{name}", + "account.copy": "Copy link to profile", "account.deactivated": "Deactivated", "account.direct": "Direct message @{name}", "account.domain_blocked": "Domain hidden", From 15186bada3ecb34860ffa4ac475f8da581502bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 21 May 2023 23:50:52 +0200 Subject: [PATCH 33/63] Proper spacing on MFA page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/security/mfa-form.tsx | 28 ++++++++-------------- 1 file changed, 10 insertions(+), 18 deletions(-) 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 && } + + )} ); }; From bfdd3a3d50031ca365e9b6dc5edfde3374844f7c Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 22 May 2023 09:47:31 -0400 Subject: [PATCH 34/63] Fix header copy --- app/soapbox/features/groups/suggested.tsx | 2 +- app/soapbox/locales/en.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/locales/en.json b/app/soapbox/locales/en.json index d46cf4ebf..f110df907 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -856,6 +856,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}", From e0056d49313184d4faa81cefd9859453a906fabc Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 22 May 2023 09:49:00 -0400 Subject: [PATCH 35/63] Reduce size of Ad icon --- app/soapbox/features/ads/components/ad.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }) => { From bf7c08d4d12fd27656bfecfd2e5fc41662e5d011 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 May 2023 11:55:30 -0500 Subject: [PATCH 36/63] DetailedStatus: remove unused props --- .../features/status/components/detailed-status.tsx | 6 +----- app/soapbox/features/status/index.tsx | 11 ----------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 15f2e86e8..cd81dd0d2 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -15,14 +15,10 @@ 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 onOpenCompareHistoryModal: (status: StatusEntity) => void onToggleMediaVisibility: () => void diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 6b41148c4..e7dbc8018 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -230,14 +230,6 @@ const Thread: React.FC = (props) => { 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); @@ -478,9 +470,6 @@ const Thread: React.FC = (props) => { Date: Thu, 11 May 2023 11:56:19 -0500 Subject: [PATCH 37/63] statusSchema: add HTML fields --- app/soapbox/schemas/status.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index ea55d5085..4905fb5a3 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -1,5 +1,9 @@ +import escapeTextContentForBrowser from 'escape-html'; import { z } from 'zod'; +import emojify from 'soapbox/features/emoji'; +import { stripCompatibilityFeatures } from 'soapbox/utils/html'; + import { accountSchema } from './account'; import { attachmentSchema } from './attachment'; import { cardSchema } from './card'; @@ -8,7 +12,7 @@ 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']), @@ -59,6 +63,17 @@ const baseStatusSchema = z.object({ const statusSchema = baseStatusSchema.extend({ quote: baseStatusSchema.nullable().catch(null), reblog: baseStatusSchema.nullable().catch(null), +}).transform((status) => { + const emojiMap = makeCustomEmojiMap(status.emojis); + + const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap)); + const spoilerHtml = emojify(escapeTextContentForBrowser(status.spoiler_text), emojiMap); + + return { + ...status, + contentHtml, + spoilerHtml, + }; }); type Status = z.infer; From 752f06b92562ef7b7bbe848b49fd9ce0a03249b9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 May 2023 12:02:55 -0500 Subject: [PATCH 38/63] actions: improve types --- app/soapbox/actions/blocks.ts | 7 ++--- app/soapbox/actions/familiar-followers.ts | 34 +++-------------------- app/soapbox/actions/interactions.ts | 4 +-- 3 files changed, 9 insertions(+), 36 deletions(-) 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..b2517301f 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -160,7 +160,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 +173,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)); From fa0bf8f5df3c63ebc543b373aa64549ca9bbfdea Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 18 May 2023 17:09:15 -0500 Subject: [PATCH 39/63] Improve statusSchema --- app/soapbox/schemas/event.ts | 20 +++++++++++ app/soapbox/schemas/location.ts | 23 ++++++++++++ app/soapbox/schemas/status.ts | 62 ++++++++++++++++++++++++++------- 3 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 app/soapbox/schemas/event.ts create mode 100644 app/soapbox/schemas/location.ts 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/location.ts b/app/soapbox/schemas/location.ts new file mode 100644 index 000000000..cbc237222 --- /dev/null +++ b/app/soapbox/schemas/location.ts @@ -0,0 +1,23 @@ +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(''), + 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 4905fb5a3..edb585aec 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -2,22 +2,19 @@ import escapeTextContentForBrowser from 'escape-html'; import { z } from 'zod'; import emojify from 'soapbox/features/emoji'; -import { stripCompatibilityFeatures } from 'soapbox/utils/html'; +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 { eventSchema } from './event'; import { groupSchema } from './group'; import { mentionSchema } from './mention'; import { pollSchema } from './poll'; import { tagSchema } from './tag'; import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; -const tombstoneSchema = z.object({ - reason: z.enum(['deleted']), -}); - const baseStatusSchema = z.object({ account: accountSchema, application: z.object({ @@ -43,27 +40,44 @@ const baseStatusSchema = z.object({ mentions: filteredArray(mentionSchema), muted: z.coerce.boolean(), pinned: z.coerce.boolean(), - pleroma: z.object({}).optional().catch(undefined), poll: pollSchema.nullable().catch(null), quote: z.literal(null).catch(null), quotes_count: z.number().catch(0), - reblog: z.literal(null).catch(null), reblogged: z.coerce.boolean(), reblogs_count: z.number().catch(0), replies_count: z.number().catch(0), 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), -}).transform((status) => { +type BaseStatus = z.infer; +type TransformableStatus = Omit; + +/** 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 || ''; +}; + +/** Add internal fields to the status. */ +const transformStatus = (status: T) => { const emojiMap = makeCustomEmojiMap(status.emojis); const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap)); @@ -73,8 +87,30 @@ const statusSchema = baseStatusSchema.extend({ ...status, contentHtml, spoilerHtml, + search_index: buildSearchIndex(status), + hidden: false, }; -}); +}; + +const embeddedStatusSchema = baseStatusSchema + .transform(transformStatus) + .nullable() + .catch(null); + +const statusSchema = baseStatusSchema.extend({ + quote: embeddedStatusSchema, + reblog: embeddedStatusSchema, + pleroma: z.object({ + event: eventSchema, + quote: embeddedStatusSchema, + }), +}).transform(({ pleroma, ...status }) => { + return { + ...status, + event: pleroma.event, + quote: pleroma.quote || status.quote, + }; +}).transform(transformStatus); type Status = z.infer; From 6062a06746fb4e4a64b1cb830b36091a652e41f4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 19 May 2023 11:13:44 -0500 Subject: [PATCH 40/63] Improve schemas for statuses --- app/soapbox/schemas/account.ts | 6 +++--- app/soapbox/schemas/attachment.ts | 6 ++++++ app/soapbox/schemas/status.ts | 28 ++++++++++++++++++++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index 919013329..f2e5f9c15 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -49,7 +49,7 @@ const accountSchema = z.object({ verified: z.boolean().default(false), website: z.string().catch(''), - /** + /* * Internal fields */ display_name_html: z.string().catch(''), @@ -57,7 +57,7 @@ const accountSchema = z.object({ note_emojified: z.string().catch(''), relationship: relationshipSchema.nullable().catch(null), - /** + /* * Misc */ other_settings: z.any(), @@ -99,7 +99,7 @@ const accountSchema = z.object({ // Notes account.note_emojified = emojify(account.note, customEmojiMap); - /** + /* * Todo * - internal fields * - donor 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/status.ts b/app/soapbox/schemas/status.ts index edb585aec..323ca5789 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -40,6 +40,9 @@ const baseStatusSchema = z.object({ mentions: filteredArray(mentionSchema), muted: z.coerce.boolean(), pinned: z.coerce.boolean(), + pleroma: z.object({ + quote_visible: z.boolean().catch(true), + }).optional().catch(undefined), poll: pollSchema.nullable().catch(null), quote: z.literal(null).catch(null), quotes_count: z.number().catch(0), @@ -58,7 +61,7 @@ const baseStatusSchema = z.object({ }); type BaseStatus = z.infer; -type TransformableStatus = Omit; +type TransformableStatus = Omit; /** Creates search index from the status. */ const buildSearchIndex = (status: TransformableStatus): string => { @@ -76,6 +79,11 @@ const buildSearchIndex = (status: TransformableStatus): string => { return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || ''; }; +type Translation = { + content: string + provider: string +} + /** Add internal fields to the status. */ const transformStatus = (status: T) => { const emojiMap = makeCustomEmojiMap(status.emojis); @@ -89,6 +97,11 @@ const transformStatus = (status: T) => { spoilerHtml, search_index: buildSearchIndex(status), hidden: false, + filtered: [], + showFiltered: false, // TODO: this should be removed from the schema and done somewhere else + approval_status: 'approval' as const, + translation: undefined as Translation | undefined, + expectsCard: false, }; }; @@ -103,12 +116,19 @@ const statusSchema = baseStatusSchema.extend({ pleroma: z.object({ event: eventSchema, quote: embeddedStatusSchema, - }), + quote_visible: z.boolean().catch(true), + }).optional().catch(undefined), }).transform(({ pleroma, ...status }) => { return { ...status, - event: pleroma.event, - quote: pleroma.quote || status.quote, + 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); From 36bbef229366af7c5c14fccd3e5c2c8b8ee52404 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 22 May 2023 11:47:49 -0500 Subject: [PATCH 41/63] Support quoted tombstone --- app/soapbox/components/tombstone.tsx | 8 ++++---- .../status/containers/quoted-status-container.tsx | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index b92fb7e70..62e7c72d2 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -6,15 +6,15 @@ 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 ( 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 ( Date: Tue, 23 May 2023 08:57:36 -0400 Subject: [PATCH 42/63] Normalize 'group_mention' notification into 'mention' --- app/soapbox/normalizers/notification.ts | 13 ++++++++++++- app/soapbox/reducers/notifications.ts | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) 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/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)]), )); From 5d1f16832578d4507d325bb7faf8970f24e25683 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 23 May 2023 09:39:17 -0400 Subject: [PATCH 43/63] Enable groups --- app/soapbox/utils/features.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d564728117b154b192fb1f6e34a0ac73491eaab5 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 23 May 2023 09:39:37 -0400 Subject: [PATCH 44/63] Add Group context to reply modal --- .../compose/components/compose-form.tsx | 3 ++ .../components/reply-group-indicator.tsx | 42 +++++++++++++++++++ app/soapbox/locales/en.json | 1 + 3 files changed, 46 insertions(+) create mode 100644 app/soapbox/features/compose/components/reply-group-indicator.tsx diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index 92364b7b0..ceea75951 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -31,6 +31,7 @@ import MarkdownButton from './markdown-button'; 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'; @@ -295,6 +296,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/locales/en.json b/app/soapbox/locales/en.json index f110df907..4a9b1e934 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -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", From 5c069b8b937f6e1a60f4fb12eb21fecb6c55ff82 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 23 May 2023 12:32:44 -0400 Subject: [PATCH 45/63] Make # members clickable to Group Members page --- .../group/components/group-member-count.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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)} + {' '} + + + ); }; From c8ff9db879ca4c39afd60f139422bd13ff1450a2 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 23 May 2023 12:32:55 -0400 Subject: [PATCH 46/63] Truncate group name in panels --- .../groups/components/discover/group-list-item.tsx | 5 +++-- .../placeholder/components/placeholder-group-search.tsx | 8 +++++--- .../features/ui/components/panels/my-groups-panel.tsx | 2 +- .../ui/components/panels/suggested-groups-panel.tsx | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) 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/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/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) => ( From 5f1bbfb19415ca965ecb4f76f2476ff1ec94bb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 27 May 2023 01:12:20 +0200 Subject: [PATCH 47/63] Fix open media hotkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/status/index.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index e7dbc8018..e79a2b8f9 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -43,11 +43,7 @@ import ThreadStatus from './components/thread-status'; import type { VirtuosoHandle } from 'react-virtuoso'; import type { RootState } from 'soapbox/store'; -import type { - Account as AccountEntity, - Attachment as AttachmentEntity, - Status as StatusEntity, -} from 'soapbox/types/entities'; +import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; const messages = defineMessages({ title: { id: 'status.title', defaultMessage: 'Post Details' }, @@ -123,8 +119,6 @@ type RouteParams = { interface IThread { params: RouteParams - onOpenMedia: (media: ImmutableList, index: number) => void - onOpenVideo: (video: AttachmentEntity, time: number) => void } const Thread: React.FC = (props) => { @@ -231,16 +225,17 @@ const Thread: React.FC = (props) => { }; const handleHotkeyOpenMedia = (e?: KeyboardEvent) => { - const { onOpenMedia, onOpenVideo } = props; - const firstAttachment = status?.media_attachments.get(0); + const media = status?.media_attachments; e?.preventDefault(); - if (status && firstAttachment) { - if (firstAttachment.type === 'video') { - onOpenVideo(firstAttachment, 0); + if (media && media.size) { + const firstAttachment = media.first()!; + + if (media.size === 1 && firstAttachment.type === 'video') { + dispatch(openModal('VIDEO', { media: firstAttachment, status: status })); } else { - onOpenMedia(status.media_attachments, 0); + dispatch(openModal('MEDIA', { media, index: 0, status: status })); } } }; From 27ba7968527623accab55c3800ead5311a66fbd2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 28 May 2023 14:45:22 -0500 Subject: [PATCH 48/63] Remove greentext support It uses Pleroma FE's vulnerable HTML parser --- app/soapbox/components/status-content.tsx | 12 +-- app/soapbox/utils/greentext.ts | 22 ----- app/soapbox/utils/tiny-post-html-processor.ts | 95 ------------------- 3 files changed, 1 insertion(+), 128 deletions(-) delete mode 100644 app/soapbox/utils/greentext.ts delete mode 100644 app/soapbox/utils/tiny-post-html-processor.ts diff --git a/app/soapbox/components/status-content.tsx b/app/soapbox/components/status-content.tsx index 2dcf673d6..5fc530769 100644 --- a/app/soapbox/components/status-content.tsx +++ b/app/soapbox/components/status-content.tsx @@ -4,8 +4,6 @@ import { FormattedMessage } from 'react-intl'; import { useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; -import { useSoapboxConfig } from 'soapbox/hooks'; -import { addGreentext } from 'soapbox/utils/greentext'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content'; import { isRtl } from '../rtl'; @@ -54,8 +52,6 @@ const StatusContent: React.FC = ({ 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/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; -}; From d14cad38af0174e9d4ff889798824f1dda45c290 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 28 May 2023 14:53:23 -0500 Subject: [PATCH 49/63] Fix soapbox config page crash --- app/soapbox/normalizers/soapbox/soapbox-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 2829a0097a689ec696ca266772f204fa7d19b244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 31 May 2023 10:45:33 +0200 Subject: [PATCH 50/63] Fix search expand when searching user' posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/search.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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)); From cbf4878f7fe715ab2403f99385cb671825ff54d5 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 25 May 2023 11:33:03 -0400 Subject: [PATCH 51/63] Add 'shift' middleware to DropdownMenu --- app/soapbox/components/dropdown-menu/dropdown-menu.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx index a5714ff68..9a606bf61 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx @@ -1,4 +1,4 @@ -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'; @@ -65,6 +65,9 @@ const DropdownMenu = (props: IDropdownMenu) => { middleware: [ offset(12), flip(), + shift({ + padding: 8, + }), arrow({ element: arrowRef, }), From 602eaf1ec151652cd89fe7c7d871066754fc6467 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 30 May 2023 09:02:03 -0400 Subject: [PATCH 52/63] Use AppDispatch --- app/soapbox/actions/account-notes.ts | 4 ++-- app/soapbox/components/attachment-thumbs.tsx | 4 ++-- app/soapbox/features/compose/components/reply-mentions.tsx | 5 ++--- .../features/crypto-donate/components/crypto-address.tsx | 4 ++-- .../features/status/components/status-interaction-bar.tsx | 5 ++--- 5 files changed, 10 insertions(+), 12 deletions(-) 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/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/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/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/status/components/status-interaction-bar.tsx b/app/soapbox/features/status/components/status-interaction-bar.tsx index 0dc6b1c08..bbbf0c115 100644 --- a/app/soapbox/features/status/components/status-interaction-bar.tsx +++ b/app/soapbox/features/status/components/status-interaction-bar.tsx @@ -2,12 +2,11 @@ import clsx from 'clsx'; import { List as ImmutableList } from 'immutable'; import React from 'react'; import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; import { HStack, Text, Emoji } from 'soapbox/components/ui'; -import { useAppSelector, useSoapboxConfig, useFeatures } from 'soapbox/hooks'; +import { useAppSelector, useSoapboxConfig, useFeatures, useAppDispatch } from 'soapbox/hooks'; import { reduceEmoji } from 'soapbox/utils/emoji-reacts'; import { shortNumberFormat } from 'soapbox/utils/numbers'; @@ -22,7 +21,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. const me = useAppSelector(({ me }) => me); const { allowedEmoji } = useSoapboxConfig(); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const features = useFeatures(); const { account } = status; From d8f698242a5fdaece73c0bc574e760a826fbf875 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 30 May 2023 09:02:22 -0400 Subject: [PATCH 53/63] Add justify-between to Stack options --- app/soapbox/components/ui/stack/stack.tsx | 1 + 1 file changed, 1 insertion(+) 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', }; From 3c00820382f599de26ba9d3d1e6f0aa44e2e521b Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 30 May 2023 09:04:04 -0400 Subject: [PATCH 54/63] Add 0 to HStack spacing --- app/soapbox/components/ui/hstack/hstack.tsx | 1 + 1 file changed, 1 insertion(+) 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', From 866c80d30baa24442ad3a9625340c5facbb61f25 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 30 May 2023 09:04:50 -0400 Subject: [PATCH 55/63] Improve the MediaModal with ability to like, comment, reply, etc --- app/soapbox/actions/modals.ts | 15 +- app/soapbox/components/modal-root.tsx | 1 + app/soapbox/components/status-action-bar.tsx | 28 +- .../components/status-action-button.tsx | 7 +- .../components/ui/icon-button/icon-button.tsx | 3 +- .../features/event/event-discussion.tsx | 2 +- .../status/components/detailed-status.tsx | 6 +- .../features/status/components/thread.tsx | 468 ++++++++++++++++++ app/soapbox/features/status/index.tsx | 449 +---------------- .../ui/components/modals/media-modal.tsx | 304 +++++++----- app/styles/components/modal.scss | 141 ------ 11 files changed, 709 insertions(+), 715 deletions(-) create mode 100644 app/soapbox/features/status/components/thread.tsx 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/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index 2358a951f..5881612bf 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..bc8f7026c 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -96,14 +96,16 @@ 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(); @@ -572,6 +574,7 @@ const StatusActionBar: React.FC = ({ onClick={handleReblogClick} count={reblogCount} text={withLabels ? intl.formatMessage(messages.reblog) : undefined} + theme={statusActionButtonTheme} /> ); @@ -583,13 +586,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 +641,7 @@ const StatusActionBar: React.FC = ({ count={emojiReactCount} emoji={meEmojiReact} text={withLabels ? meEmojiTitle : undefined} + theme={statusActionButtonTheme} /> ) : ( @@ -640,6 +654,7 @@ const StatusActionBar: React.FC = ({ active={Boolean(meEmojiName)} count={favouriteCount} text={withLabels ? meEmojiTitle : undefined} + theme={statusActionButtonTheme} /> )} @@ -653,6 +668,7 @@ const StatusActionBar: React.FC = ({ active={status.disliked} count={status.dislikes_count} text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined} + theme={statusActionButtonTheme} /> )} @@ -661,6 +677,7 @@ const StatusActionBar: React.FC = ({ title={intl.formatMessage(messages.share)} icon={require('@tabler/icons/upload.svg')} onClick={handleShareClick} + theme={statusActionButtonTheme} /> )} @@ -668,6 +685,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 { /** 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/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/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index cd81dd0d2..46b2a338e 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -19,7 +19,8 @@ import type { Group, Status as StatusEntity } from 'soapbox/types/entities'; interface IDetailedStatus { status: StatusEntity - showMedia: boolean + showMedia?: boolean + withMedia?: boolean onOpenCompareHistoryModal: (status: StatusEntity) => void onToggleMediaVisibility: () => void } @@ -29,6 +30,7 @@ const DetailedStatus: React.FC = ({ onOpenCompareHistoryModal, onToggleMediaVisibility, showMedia, + withMedia = true, }) => { const intl = useIntl(); @@ -151,7 +153,7 @@ const DetailedStatus: React.FC = ({ - {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( + {(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.size > 0)) && ( 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/index.tsx b/app/soapbox/features/status/index.tsx index e79a2b8f9..f2b882e0a 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -1,49 +1,20 @@ -import clsx from 'clsx'; -import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; import debounce from 'lodash/debounce'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { HotKeys } from 'react-hotkeys'; +import React, { useCallback, useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { Redirect, useHistory } from 'react-router-dom'; -import { createSelector } from 'reselect'; +import { Redirect } from 'react-router-dom'; import { - replyCompose, - mentionCompose, -} from 'soapbox/actions/compose'; -import { - favourite, - unfavourite, - reblog, - unreblog, -} from 'soapbox/actions/interactions'; -import { openModal } from 'soapbox/actions/modals'; -import { getSettings } from 'soapbox/actions/settings'; -import { - hideStatus, - revealStatus, fetchStatusWithContext, fetchNext, } from 'soapbox/actions/statuses'; import MissingIndicator from 'soapbox/components/missing-indicator'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; -import ScrollableList from 'soapbox/components/scrollable-list'; -import StatusActionBar from 'soapbox/components/status-action-bar'; -import Tombstone from 'soapbox/components/tombstone'; -import { Column, Stack } from 'soapbox/components/ui'; +import { Column } 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, useSettings } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; -import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; -import DetailedStatus from './components/detailed-status'; -import ThreadLoginCta from './components/thread-login-cta'; -import ThreadStatus from './components/thread-status'; - -import type { VirtuosoHandle } from 'react-virtuoso'; -import type { RootState } from 'soapbox/store'; -import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; +import Thread from './components/thread'; const messages = defineMessages({ title: { id: 'status.title', defaultMessage: 'Post Details' }, @@ -63,104 +34,26 @@ const messages = defineMessages({ blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); -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; -}); - -type DisplayMedia = 'default' | 'hide_all' | 'show_all'; - type RouteParams = { statusId: string groupId?: string groupSlug?: string }; -interface IThread { +interface IStatusDetails { params: RouteParams } -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; @@ -173,234 +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 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: 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 }) => { @@ -409,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 ( @@ -436,73 +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 ; @@ -517,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/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index 530b1e6cc..18c24a5da 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -1,15 +1,21 @@ 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 { makeGetStatus } from 'soapbox/selectors'; import ImageLoader from '../image-loader'; @@ -18,16 +24,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 +59,24 @@ 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(false); - const handleSwipe = (index: number) => { - setIndex(index % media.size); - }; + const hasMultipleImages = media.size > 1; - const handleNextClick = () => { - setIndex((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 handleSwipe = (index: number) => setIndex(index % media.size); + const handleNextClick = () => setIndex((getIndex() + 1) % media.size); + const handlePrevClick = () => setIndex((media.size + getIndex() - 1) % media.size); const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { @@ -77,13 +93,10 @@ 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; @@ -105,61 +118,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 +188,154 @@ 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 (!actualStatus && isLoaded) { + return ( + + ); + } else if (!actualStatus) { + return ; + } return ( -
    +
    - - {content} - -
    + + -
    - + + - {leftNav} - {rightNav} + setIsFullScreen(!isFullScreen)} + /> + + - {(status && !isMultiMedia[getIndex()]) && ( -
    1 })}> - - - + {/* Height based on height of top/bottom bars */} +
    + {hasMultipleImages && ( +
    + +
    + )} + + + {content} + + + {hasMultipleImages && ( +
    + +
    + )}
    - )} -
      - {pagination} -
    + + + + + +
    ); 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 { From 5ff9c1c6ec2d2b66c95623cf51e27c729370850b Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 31 May 2023 08:54:54 -0400 Subject: [PATCH 56/63] Fix tests --- app/soapbox/actions/__tests__/account-notes.test.ts | 1 + app/soapbox/actions/__tests__/statuses.test.ts | 1 + .../features/ui/components/__tests__/compose-button.test.tsx | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) 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/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); }); }); From a136deb13e1e20c2098111bba9825469a66eeda0 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 31 May 2023 09:22:03 -0400 Subject: [PATCH 57/63] Update i18n --- app/soapbox/locales/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 4a9b1e934..6d971eb3b 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -931,6 +931,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", From 88630e7cf7cdea5c25a7cfcfde0d73c15bc2e617 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 31 May 2023 11:23:28 -0400 Subject: [PATCH 58/63] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d312320..5f1c73a30 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. From 99e262ab8a239a40cd96d0f42834fdf22d782264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 31 May 2023 20:34:08 +0200 Subject: [PATCH 59/63] Fix shouldPersistSearch for Pleroma flake ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/compose/components/search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From aca4322526e498e825fcec7068bc949d323e13d1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 31 May 2023 20:01:30 -0500 Subject: [PATCH 60/63] MediaModal: support not having a status --- .../ui/components/modals/media-modal.tsx | 85 ++++++++++--------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/app/soapbox/features/ui/components/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index 18c24a5da..1348e1e96 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -70,7 +70,7 @@ const MediaModal: React.FC = (props) => { const [next, setNext] = useState(); const [index, setIndex] = useState(null); const [navigationHidden, setNavigationHidden] = useState(false); - const [isFullScreen, setIsFullScreen] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(!status); const hasMultipleImages = media.size > 1; @@ -219,12 +219,14 @@ const MediaModal: React.FC = (props) => { }; }, [index]); - if (!actualStatus && isLoaded) { - return ( - - ); - } else if (!actualStatus) { - return ; + if (status) { + if (!actualStatus && isLoaded) { + return ( + + ); + } else if (!actualStatus) { + return ; + } } return ( @@ -255,21 +257,22 @@ const MediaModal: React.FC = (props) => { - setIsFullScreen(!isFullScreen)} - /> + {status && ( + setIsFullScreen(!isFullScreen)} + /> + )}
    @@ -311,31 +314,35 @@ const MediaModal: React.FC = (props) => { )}
    - - - + {actualStatus && ( + + + + )} - + {actualStatus && ( + + )}
    ); From 8731bcee3ed1a017912cc82541d6fc585b2bc910 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 31 May 2023 20:04:34 -0500 Subject: [PATCH 61/63] Thread: remove border-radius --- app/styles/components/detailed-status.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 87b279d8eee961b5a9653886b5c5ea727099c25a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 1 Jun 2023 11:49:36 -0500 Subject: [PATCH 62/63] MediaModal: hide fullscreen button on mobile --- app/soapbox/features/ui/components/modals/media-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/ui/components/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index 1348e1e96..e59b90499 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -268,7 +268,7 @@ const MediaModal: React.FC = (props) => { src={isFullScreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')} title={intl.formatMessage(isFullScreen ? messages.minimize : messages.expand)} theme='dark' - className='!p-1.5 hover:scale-105 hover:bg-gray-900' + className='hidden !p-1.5 hover:scale-105 hover:bg-gray-900 xl:block' iconClassName='h-5 w-5' onClick={() => setIsFullScreen(!isFullScreen)} /> From 0d8317145d18272c77b325dd67bd0dd860e788d9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 1 Jun 2023 12:52:37 -0500 Subject: [PATCH 63/63] MediaModal: make clicking the background close the modal Fixes https://gitlab.com/soapbox-pub/soapbox/-/issues/1434 --- app/soapbox/features/ui/components/modals/media-modal.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/soapbox/features/ui/components/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index e59b90499..959c433fd 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -229,6 +229,12 @@ const MediaModal: React.FC = (props) => { } } + const handleClickOutside: React.MouseEventHandler = (e) => { + if ((e.target as HTMLElement).tagName === 'DIV') { + onClose(); + } + }; + return (
    = (props) => { role='presentation' >