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 01/58] 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 02/58] 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 03/58] 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 04/58] 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 05/58] 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 06/58] 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 07/58] 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 08/58] 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 09/58] 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 10/58] 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 11/58] 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 12/58] 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 13/58] 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 14/58] 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 15/58] 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 16/58] 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 17/58] 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 18/58] 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 19/58] 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 20/58] 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 21/58] 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 22/58] 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 23/58] 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 24/58] 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 25/58] 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 26/58] 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 27/58] 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 28/58] 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 29/58] 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 b45d1ea7fa19b6439fc492f634ce78495ba5eabf Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 1 Jun 2023 12:41:23 -0400 Subject: [PATCH 30/58] Fix dark border color --- app/soapbox/components/ui/menu/menu.css | 2 +- app/soapbox/features/status/components/thread.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/components/ui/menu/menu.css b/app/soapbox/components/ui/menu/menu.css index 96f510304..123f0942a 100644 --- a/app/soapbox/components/ui/menu/menu.css +++ b/app/soapbox/components/ui/menu/menu.css @@ -1,5 +1,5 @@ [data-reach-menu-popover] { - @apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 focus:outline-none z-[1003]; + @apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-gray-800 focus:outline-none z-[1003]; } [data-reach-menu-button] { diff --git a/app/soapbox/features/status/components/thread.tsx b/app/soapbox/features/status/components/thread.tsx index a02bb6b97..aa563e839 100644 --- a/app/soapbox/features/status/components/thread.tsx +++ b/app/soapbox/features/status/components/thread.tsx @@ -387,7 +387,7 @@ const Thread = (props: IThread) => { {!isUnderReview ? ( <> -
    +
    { {hasDescendants && ( -
    +
    )}
    ); From e62f292aa8b3ff155ad74e2104715dd7f9d4ed29 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 1 Jun 2023 12:44:11 -0400 Subject: [PATCH 31/58] Use dark-mode badges for Group members --- .../features/group/components/group-member-list-item.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index f9b18735d..a66a2db6e 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -195,8 +195,8 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { data-testid='role-badge' className={ clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', { - 'bg-primary-200 text-primary-500': isMemberOwner, - 'bg-gray-200 text-gray-900': isMemberAdmin, + 'bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200': isMemberOwner, + 'bg-gray-200 text-gray-900 dark:bg-gray-800 dark:text-gray-100': isMemberAdmin, }) } > From 92f824bb2ddabc130f51948aa7df36651a42e807 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 1 Jun 2023 12:47:51 -0400 Subject: [PATCH 32/58] Fix dark border color on Tombstones --- app/soapbox/components/tombstone.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index 62e7c72d2..2c1c0187e 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -21,7 +21,7 @@ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => {
    Date: Thu, 1 Jun 2023 11:49:36 -0500 Subject: [PATCH 33/58] 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 34/58] 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' > Date: Fri, 2 Jun 2023 10:28:10 -0400 Subject: [PATCH 35/58] Prevent lookup if not enabled --- app/soapbox/api/hooks/groups/useGroupLookup.ts | 2 +- app/soapbox/entity-store/hooks/useEntityLookup.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts index 6e41975e5..a9cb2b369 100644 --- a/app/soapbox/api/hooks/groups/useGroupLookup.ts +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -12,7 +12,7 @@ function useGroupLookup(slug: string) { Entities.GROUPS, (group) => group.slug === slug, () => api.get(`/api/v1/groups/lookup?name=${slug}`), - { schema: groupSchema }, + { schema: groupSchema, enabled: !!slug }, ); const { entity: relationship } = useGroupRelationship(group?.id); diff --git a/app/soapbox/entity-store/hooks/useEntityLookup.ts b/app/soapbox/entity-store/hooks/useEntityLookup.ts index a49a659a4..73b2ef938 100644 --- a/app/soapbox/entity-store/hooks/useEntityLookup.ts +++ b/app/soapbox/entity-store/hooks/useEntityLookup.ts @@ -25,6 +25,7 @@ function useEntityLookup( const [isFetching, setPromise] = useLoading(true); const entity = useAppSelector(state => findEntity(state, entityType, lookupFn)); + const isEnabled = opts.enabled ?? true; const isLoading = isFetching && !entity; const fetchEntity = async () => { @@ -38,10 +39,12 @@ function useEntityLookup( }; useEffect(() => { + if (!isEnabled) return; + if (!entity || opts.refetch) { fetchEntity(); } - }, []); + }, [isEnabled]); return { entity, From a2a1bab517028675cecf980713bd8fece77067b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 2 Jun 2023 21:40:32 +0200 Subject: [PATCH 36/58] Dropdown menu improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../dropdown-menu/dropdown-menu-item.tsx | 4 +- .../dropdown-menu/dropdown-menu.tsx | 67 +++++++------------ 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx index 8a6c8f531..e236983fc 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx @@ -87,7 +87,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { title={item.text} className={ clsx({ - 'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true, + 'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 focus:outline-none cursor-pointer': true, 'text-danger-600 dark:text-danger-400': item.destructive, }) } @@ -106,4 +106,4 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { ); }; -export default DropdownMenuItem; \ No newline at end of file +export default DropdownMenuItem; diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx index 9a606bf61..48fff7398 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx @@ -4,12 +4,9 @@ import { supportsPassiveEvents } from 'detect-passive-events'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { - closeDropdownMenu as closeDropdownMenuRedux, - openDropdownMenu, -} from 'soapbox/actions/dropdown-menu'; +import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu'; import { closeModal, openModal } from 'soapbox/actions/modals'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch } from 'soapbox/hooks'; import { isUserTouching } from 'soapbox/is-mobile'; import { IconButton, Portal } from '../ui'; @@ -53,10 +50,8 @@ const DropdownMenu = (props: IDropdownMenu) => { const history = useHistory(); const [isOpen, setIsOpen] = useState(false); - const isOpenRedux = useAppSelector(state => state.dropdown_menu.isOpen); const arrowRef = useRef(null); - const activeElement = useRef(null); const isOnMobile = isUserTouching(); @@ -116,10 +111,7 @@ const DropdownMenu = (props: IDropdownMenu) => { }; const handleClose = () => { - if (activeElement.current && activeElement.current === refs.reference.current) { - (activeElement.current as any).focus(); - activeElement.current = null; - } + (refs.reference.current as HTMLButtonElement)?.focus(); if (isOnMobile) { dispatch(closeModal('ACTIONS')); @@ -134,24 +126,13 @@ const DropdownMenu = (props: IDropdownMenu) => { }; const closeDropdownMenu = () => { - if (isOpenRedux) { - dispatch(closeDropdownMenuRedux()); - } - }; + dispatch((dispatch, getState) => { + const isOpenRedux = getState().dropdown_menu.isOpen; - const handleMouseDown: React.EventHandler = () => { - if (!isOpen) { - activeElement.current = document.activeElement; - } - }; - - const handleButtonKeyDown: React.EventHandler = (event) => { - switch (event.key) { - case ' ': - case 'Enter': - handleMouseDown(event); - break; - } + if (isOpenRedux) { + dispatch(closeDropdownMenuRedux()); + } + }); }; const handleKeyPress: React.EventHandler> = (event) => { @@ -263,16 +244,22 @@ const DropdownMenu = (props: IDropdownMenu) => { }, []); useEffect(() => { - document.addEventListener('click', handleDocumentClick, false); - document.addEventListener('keydown', handleKeyDown, false); - document.addEventListener('touchend', handleDocumentClick, listenerOptions); + if (isOpen) { + if (refs.floating.current) { + (refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus(); + } - return () => { - document.removeEventListener('click', handleDocumentClick); - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('touchend', handleDocumentClick); - }; - }, [refs.floating.current]); + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('keydown', handleKeyDown, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('touchend', handleDocumentClick); + }; + } + }, [isOpen, refs.floating.current]); if (items.length === 0) { return null; @@ -284,8 +271,6 @@ const DropdownMenu = (props: IDropdownMenu) => { React.cloneElement(children, { disabled, onClick: handleClick, - onMouseDown: handleMouseDown, - onKeyDown: handleButtonKeyDown, onKeyPress: handleKeyPress, ref: refs.setReference, }) @@ -299,8 +284,6 @@ const DropdownMenu = (props: IDropdownMenu) => { title={title} src={src} onClick={handleClick} - onMouseDown={handleMouseDown} - onKeyDown={handleButtonKeyDown} onKeyPress={handleKeyPress} ref={refs.setReference} /> @@ -346,4 +329,4 @@ const DropdownMenu = (props: IDropdownMenu) => { ); }; -export default DropdownMenu; \ No newline at end of file +export default DropdownMenu; From c80633fd9f4b289290d036edb66e345407abc5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 3 Jun 2023 20:55:04 +0200 Subject: [PATCH 37/58] Media modal: Restore navigationHidden functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../ui/components/modals/media-modal.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app/soapbox/features/ui/components/modals/media-modal.tsx b/app/soapbox/features/ui/components/modals/media-modal.tsx index 959c433fd..875506175 100644 --- a/app/soapbox/features/ui/components/modals/media-modal.tsx +++ b/app/soapbox/features/ui/components/modals/media-modal.tsx @@ -15,6 +15,7 @@ import PlaceholderStatus from 'soapbox/features/placeholder/components/placehold import Thread from 'soapbox/features/status/components/thread'; import Video from 'soapbox/features/video'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { isUserTouching } from 'soapbox/is-mobile'; import { makeGetStatus } from 'soapbox/selectors'; import ImageLoader from '../image-loader'; @@ -78,6 +79,8 @@ const MediaModal: React.FC = (props) => { const handleNextClick = () => setIndex((getIndex() + 1) % media.size); const handlePrevClick = () => setIndex((media.size + getIndex() - 1) % media.size); + const navigationHiddenClassName = navigationHidden ? 'pointer-events-none opacity-0' : ''; + const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowLeft': @@ -101,7 +104,7 @@ const MediaModal: React.FC = (props) => { const getIndex = () => index !== null ? index : props.index; const toggleNavigation = () => { - setNavigationHidden(!navigationHidden); + setNavigationHidden(value => !value && isUserTouching()); }; const handleStatusClick: React.MouseEventHandler = e => { @@ -251,7 +254,11 @@ const MediaModal: React.FC = (props) => { } justifyContent='between' > - + = (props) => { {/* Height based on height of top/bottom bars */} -
    +
    {hasMultipleImages && ( -
    +
    {actualStatus && ( - + Date: Sat, 3 Jun 2023 20:31:21 +0200 Subject: [PATCH 38/58] Minor strings improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../features/group/components/group-member-list-item.tsx | 4 ++-- app/soapbox/locales/en.json | 4 ++-- app/soapbox/pages/group-page.tsx | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index a66a2db6e..a2c2c951c 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -22,7 +22,7 @@ import type { Group, GroupMember } from 'soapbox/types/entities'; const messages = defineMessages({ adminLimitTitle: { id: 'group.member.admin.limit.title', defaultMessage: 'Admin limit reached' }, - adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count} admins for the group at this time.' }, + adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count, plural, one {admin} other {admins}} for the group at this time.' }, blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' }, blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' }, blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' }, @@ -210,4 +210,4 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { ); }; -export default GroupMemberListItem; \ No newline at end of file +export default GroupMemberListItem; diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 6d971eb3b..2541b0940 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -767,7 +767,7 @@ "gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.", "gdpr.title": "{siteTitle} uses cookies", "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", - "group.banned.message": "You are banned from", + "group.banned.message": "You are banned from {group}", "group.cancel_request": "Cancel Request", "group.delete.success": "Group successfully deleted", "group.deleted.message": "This group has been deleted.", @@ -791,7 +791,7 @@ "group.leave.label": "Leave", "group.leave.success": "Left the group", "group.manage": "Manage Group", - "group.member.admin.limit.summary": "You can assign up to {count} admins for the group at this time.", + "group.member.admin.limit.summary": "You can assign up to {count, plural, one {admin} other {admins}} for the group at this time.", "group.member.admin.limit.title": "Admin limit reached", "group.popover.action": "View Group", "group.popover.summary": "You must be a member of the group in order to reply to this status.", diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index 41d561604..e88441432 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -82,10 +82,11 @@ const BlockedBlankslate = ({ group }: { group: Group }) => ( , + }} /> - {' '} - ); From 6cd8e50493c2390f1a16e34680acbf1d05283821 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 30 May 2023 12:08:28 -0400 Subject: [PATCH 39/58] Allow admins to delete Group statuses --- app/soapbox/components/status-action-bar.tsx | 30 ++++++++++++-------- app/soapbox/normalizers/group.ts | 3 ++ app/soapbox/schemas/group.ts | 1 + 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index bc8f7026c..0d7382d91 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -460,18 +460,24 @@ const StatusActionBar: React.FC = ({ }); } - if (status.group && - groupRelationship?.role && - [GroupRoles.OWNER].includes(groupRelationship.role) && - !ownAccount - ) { - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.groupModDelete), - action: handleDeleteFromGroup, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); + const isGroupStatus = typeof status.group === 'object'; + if (isGroupStatus && !!status.group) { + const group = status.group as Group; + const account = status.account as Account; + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + const isGroupAdmin = groupRelationship?.role === GroupRoles.ADMIN; + const isStatusFromOwner = group.owner.id === account.id; + const canDeleteStatus = !ownAccount && (isGroupOwner || (isGroupAdmin && !isStatusFromOwner)); + + if (canDeleteStatus) { + menu.push(null); + menu.push({ + text: intl.formatMessage(messages.groupModDelete), + action: handleDeleteFromGroup, + icon: require('@tabler/icons/trash.svg'), + destructive: true, + }); + } } if (isStaff) { diff --git a/app/soapbox/normalizers/group.ts b/app/soapbox/normalizers/group.ts index 152a6c3c4..c5f34aafb 100644 --- a/app/soapbox/normalizers/group.ts +++ b/app/soapbox/normalizers/group.ts @@ -32,6 +32,9 @@ export const GroupRecord = ImmutableRecord({ locked: false, membership_required: false, members_count: 0, + owner: { + id: '', + }, note: '', statuses_visibility: 'public', slug: '', diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index d5ee7f2ee..be9238308 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -27,6 +27,7 @@ const groupSchema = z.object({ locked: z.boolean().catch(false), membership_required: z.boolean().catch(false), members_count: z.number().catch(0), + owner: z.object({ id: z.string() }), note: z.string().transform(note => note === '

    ' ? '' : note).catch(''), relationship: groupRelationshipSchema.nullable().catch(null), // Dummy field to be overwritten later slug: z.string().catch(''), // TruthSocial From 60875e5dc22eb4c4b90bbce4915fb58c1c9d90bf Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 31 May 2023 08:33:25 -0400 Subject: [PATCH 40/58] Add 'owner' to Group fixture --- .../__fixtures__/group-truthsocial.json | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/soapbox/__fixtures__/group-truthsocial.json b/app/soapbox/__fixtures__/group-truthsocial.json index f874f6892..63d8b14d5 100644 --- a/app/soapbox/__fixtures__/group-truthsocial.json +++ b/app/soapbox/__fixtures__/group-truthsocial.json @@ -1,16 +1,19 @@ { - "note": "patriots 900000001", - "discoverable": true, - "id": "109989480368015378", - "domain": null, "avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg", "avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg", + "created_at": "2023-03-08T00:00:00.000Z", + "discoverable": true, + "display_name": "PATRIOT PATRIOTS", + "domain": null, + "group_visibility": "everyone", "header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png", "header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png", - "group_visibility": "everyone", - "created_at": "2023-03-08T00:00:00.000Z", - "display_name": "PATRIOT PATRIOTS", - "membership_required": true, + "id": "109989480368015378", "members_count": 1, + "membership_required": true, + "note": "patriots 900000001", + "owner": { + "id": "424023483294040" + }, "tags": [] } \ No newline at end of file From d75920718f172d7f35d830e12dce773249acdce8 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 31 May 2023 08:57:43 -0400 Subject: [PATCH 41/58] Fix group factory --- app/soapbox/jest/factory.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index 019b0b17f..9bc4217f6 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -40,6 +40,9 @@ function buildCard(props: Partial = {}): Card { function buildGroup(props: Partial = {}): Group { return groupSchema.parse(Object.assign({ id: uuidv4(), + owner: { + id: uuidv4(), + }, }, props)); } From 45e0726e596be13e135a44143aeac2d7f48e5e38 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 30 May 2023 14:42:53 -0400 Subject: [PATCH 42/58] Alphabetize messages --- app/soapbox/components/status-action-bar.tsx | 100 +++++++++---------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 0d7382d91..7d3d540da 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -32,64 +32,64 @@ import type { Menu } from 'soapbox/components/dropdown-menu'; import type { Account, Group, Status } from 'soapbox/types/entities'; const messages = defineMessages({ - delete: { id: 'status.delete', defaultMessage: 'Delete' }, - redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, - edit: { id: 'status.edit', defaultMessage: 'Edit' }, - direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, - chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, - mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, - reply: { id: 'status.reply', defaultMessage: 'Reply' }, - share: { id: 'status.share', defaultMessage: 'Share' }, - more: { id: 'status.more', defaultMessage: 'More' }, - replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, - reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Like' }, - disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, - open: { id: 'status.open', defaultMessage: 'Expand this post' }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, - report: { id: 'status.report', defaultMessage: 'Report @{name}' }, - muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, - unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, - unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, - embed: { id: 'status.embed', defaultMessage: 'Embed' }, adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, + chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, - group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, - external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, - markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, - markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, - reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, - reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, - reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, - reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, - reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, - reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, - quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + delete: { id: 'status.delete', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, - redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, - replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, + deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, + external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, + favourite: { id: 'status.favourite', defaultMessage: 'Like' }, groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' }, - deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, + group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, + group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, + markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, + markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + more: { id: 'status.more', defaultMessage: 'More' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + open: { id: 'status.open', defaultMessage: 'Expand this post' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, + reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, + reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, + reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, + reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, + reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, + replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, }); interface IStatusActionBar { From 35d9348edd5ee0f500ac99fc6b11d38e9a1d2d9f Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 09:39:01 -0400 Subject: [PATCH 43/58] Support Group pins --- app/soapbox/actions/interactions.ts | 19 +++++++++++- app/soapbox/actions/timelines.ts | 4 +++ app/soapbox/components/status-action-bar.tsx | 31 +++++++++++++++++-- app/soapbox/features/group/group-timeline.tsx | 8 ++++- app/soapbox/locales/en.json | 3 ++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index b2517301f..77bccb41f 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -7,10 +7,11 @@ import api from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { expandGroupFeaturedTimeline } from './timelines'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; +import type { APIEntity, Group, Status as StatusEntity } from 'soapbox/types/entities'; const REBLOG_REQUEST = 'REBLOG_REQUEST'; const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -511,6 +512,20 @@ const pin = (status: StatusEntity) => }); }; +const pinToGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/pin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + +const unpinFromGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/unpin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + const pinRequest = (status: StatusEntity) => ({ type: PIN_REQUEST, status, @@ -715,6 +730,8 @@ export { unpinSuccess, unpinFail, togglePin, + pinToGroup, + unpinFromGroup, remoteInteraction, remoteInteractionRequest, remoteInteractionSuccess, diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 902b99f70..1df112dae 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -248,6 +248,9 @@ const expandListTimeline = (id: string, { maxId }: Record = {}, don const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); +const expandGroupFeaturedTimeline = (id: string) => + expandTimeline(`group:${id}:pinned`, `/api/v1/timelines/group/${id}`, { pinned: true }); + const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done); @@ -353,6 +356,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupFeaturedTimeline, expandGroupTimelineFromTag, expandGroupMediaTimeline, expandHashtagTimeline, diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 7d3d540da..3e6cae67a 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -7,7 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts'; import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; -import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; +import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; @@ -67,6 +67,9 @@ const messages = defineMessages({ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, open: { id: 'status.open', defaultMessage: 'Expand this post' }, pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' }, + pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned to Group!' }, + unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, @@ -232,6 +235,18 @@ const StatusActionBar: React.FC = ({ dispatch(togglePin(status)); }; + const handleGroupPinClick: React.EventHandler = () => { + const group = status.group as Group; + + if (status.pinned) { + dispatch(unpinFromGroup(status, group)); + } else { + dispatch(pinToGroup(status, group)) + .then(() => toast.success(intl.formatMessage(messages.pinToGroupSuccess))) + .catch(() => null); + } + }; + const handleMentionClick: React.EventHandler = (e) => { dispatch(mentionCompose(status.account as Account)); }; @@ -358,6 +373,19 @@ const StatusActionBar: React.FC = ({ return menu; } + const isGroupStatus = typeof status.group === 'object'; + if (isGroupStatus && !!status.group) { + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + + if (isGroupOwner) { + menu.push({ + text: intl.formatMessage(status.pinned ? messages.unpinFromGroup : messages.pinToGroup), + action: handleGroupPinClick, + icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), + }); + } + } + if (features.bookmarks) { menu.push({ text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), @@ -460,7 +488,6 @@ const StatusActionBar: React.FC = ({ }); } - const isGroupStatus = typeof status.group === 'object'; if (isGroupStatus && !!status.group) { const group = status.group as Group; const account = status.account as Account; diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx index a920a862c..3c736cd78 100644 --- a/app/soapbox/features/group/group-timeline.tsx +++ b/app/soapbox/features/group/group-timeline.tsx @@ -5,11 +5,12 @@ import { Link } from 'react-router-dom'; import { groupCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; import { connectGroupStream } from 'soapbox/actions/streaming'; -import { expandGroupTimeline } from 'soapbox/actions/timelines'; +import { expandGroupFeaturedTimeline, expandGroupTimeline } from 'soapbox/actions/timelines'; import { useGroup } from 'soapbox/api/hooks'; import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui'; import ComposeForm from 'soapbox/features/compose/components/compose-form'; import { useAppDispatch, useAppSelector, useDraggedFiles, useOwnAccount } from 'soapbox/hooks'; +import { makeGetStatusIds } from 'soapbox/selectors'; import Timeline from '../ui/components/timeline'; @@ -19,6 +20,8 @@ interface IGroupTimeline { params: RouteParams } +const getStatusIds = makeGetStatusIds(); + const GroupTimeline: React.FC = (props) => { const intl = useIntl(); const account = useOwnAccount(); @@ -32,6 +35,7 @@ const GroupTimeline: React.FC = (props) => { const composeId = `group:${groupId}`; const canComposeGroupStatus = !!account && group?.relationship?.member; const groupTimelineVisible = useAppSelector((state) => !!state.compose.get(composeId)?.group_timeline_visible); + const featuredStatusIds = useAppSelector((state) => getStatusIds(state, { type: `group:${group?.id}:pinned` })); const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => { dispatch(uploadCompose(composeId, files, intl)); @@ -47,6 +51,7 @@ const GroupTimeline: React.FC = (props) => { useEffect(() => { dispatch(expandGroupTimeline(groupId)); + dispatch(expandGroupFeaturedTimeline(groupId)); dispatch(groupCompose(composeId, groupId)); const disconnect = dispatch(connectGroupStream(groupId)); @@ -123,6 +128,7 @@ const GroupTimeline: React.FC = (props) => { emptyMessageCard={false} divideType='border' showGroup={false} + featuredStatusIds={featuredStatusIds} /> ); diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 2541b0940..bbfce9aa8 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -1466,6 +1466,8 @@ "status.mute_conversation": "Mute Conversation", "status.open": "Show Post Details", "status.pin": "Pin on profile", + "status.pin_to_group": "Pin to Group", + "status.pin_to_group.success": "Pinned to Group!", "status.pinned": "Pinned post", "status.quote": "Quote post", "status.reactions.cry": "Sad", @@ -1502,6 +1504,7 @@ "status.unbookmarked": "Bookmark removed.", "status.unmute_conversation": "Unmute Conversation", "status.unpin": "Unpin from profile", + "status.unpin_to_group": "Unpin from Group", "status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}", "statuses.quote_tombstone": "Post is unavailable.", "statuses.tombstone": "One or more posts are unavailable.", From cae9d9158d9600d8811faa2303e9a745b1477390 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 10:25:48 -0400 Subject: [PATCH 44/58] Fix typo --- app/soapbox/features/verification/steps/age-verification.tsx | 2 +- app/soapbox/locales/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/verification/steps/age-verification.tsx b/app/soapbox/features/verification/steps/age-verification.tsx index faf3c6c50..5129f5464 100644 --- a/app/soapbox/features/verification/steps/age-verification.tsx +++ b/app/soapbox/features/verification/steps/age-verification.tsx @@ -61,7 +61,7 @@ const AgeVerification = () => { Date: Mon, 5 Jun 2023 10:41:21 -0400 Subject: [PATCH 45/58] Add redirect from statuses -> posts --- app/soapbox/features/ui/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 7653dd7fc..dcdd7fecb 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -334,6 +334,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groups && } {features.groups && } {features.groups && } + {features.groups && } From 53a012d9bb1d5431b6c23a1a975715776ad52c65 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 11:11:57 -0400 Subject: [PATCH 46/58] Fix navigation bug on Group profiles --- app/soapbox/pages/group-page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index e88441432..1803de51c 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -139,7 +139,7 @@ const GroupPage: React.FC = ({ params, children }) => { ); return items; - }, [features.groupsTags, pending.length]); + }, [features.groupsTags, pending.length, group?.slug]); const renderChildren = () => { if (isDeleted) { @@ -160,6 +160,7 @@ const GroupPage: React.FC = ({ params, children }) => { From 27f831dce92e3c462f48994d849b7baebf12c7f5 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 12:19:59 -0400 Subject: [PATCH 47/58] Improve emoji search --- app/soapbox/features/emoji/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/emoji/search.ts b/app/soapbox/features/emoji/search.ts index dbcb29756..2330d17f0 100644 --- a/app/soapbox/features/emoji/search.ts +++ b/app/soapbox/features/emoji/search.ts @@ -12,7 +12,7 @@ const index = new Index({ }); for (const [key, emoji] of Object.entries(data.emojis)) { - index.add('n' + key, emoji.name); + index.add('n' + key, `${emoji.keywords.join(' ')} ${emoji.name}`); } export interface searchOptions { From 1fed96e99e0fdc37e4fdd443057bef81408430d4 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 12:39:32 -0400 Subject: [PATCH 48/58] Sort the emojis --- app/soapbox/features/emoji/search.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/emoji/search.ts b/app/soapbox/features/emoji/search.ts index 2330d17f0..c4953eda9 100644 --- a/app/soapbox/features/emoji/search.ts +++ b/app/soapbox/features/emoji/search.ts @@ -11,8 +11,10 @@ const index = new Index({ context: true, }); -for (const [key, emoji] of Object.entries(data.emojis)) { - index.add('n' + key, `${emoji.keywords.join(' ')} ${emoji.name}`); +// console.log(Object.entries(data.emojis)); +const sortedEmojis = Object.entries(data.emojis).sort((a, b) => a[0].localeCompare(b[0])); +for (const [key, emoji] of sortedEmojis) { + index.add('n' + key, `${emoji.id} ${emoji.name} ${emoji.keywords.join(' ')}`); } export interface searchOptions { From 476ae0a68d49b605d2b422599691e69de9053fc7 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 13:17:48 -0400 Subject: [PATCH 49/58] Fix emoji search test --- .../emoji/__tests__/emoji-index.test.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/app/soapbox/features/emoji/__tests__/emoji-index.test.ts b/app/soapbox/features/emoji/__tests__/emoji-index.test.ts index 59e2e9bef..925e2627a 100644 --- a/app/soapbox/features/emoji/__tests__/emoji-index.test.ts +++ b/app/soapbox/features/emoji/__tests__/emoji-index.test.ts @@ -19,22 +19,12 @@ describe('emoji_index', () => { it('orders search results correctly', () => { const expected = [ - { - id: 'pineapple', - unified: '1f34d', - native: '🍍', - }, - { - id: 'apple', - unified: '1f34e', - native: '🍎', - }, - { - id: 'green_apple', - unified: '1f34f', - native: '🍏', - }, + { id: 'apple', unified: '1f34e', native: '🍎' }, + { id: 'pineapple', unified: '1f34d', native: '🍍' }, + { id: 'green_apple', unified: '1f34f', native: '🍏' }, + { id: 'iphone', unified: '1f4f1', native: '📱' }, ]; + expect(search('apple').map(trimEmojis)).toEqual(expected); }); From c5ec7556617045faaedc4fef2c0ef12f4275f385 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 5 Jun 2023 14:21:04 -0400 Subject: [PATCH 50/58] Remove log --- app/soapbox/features/emoji/search.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/soapbox/features/emoji/search.ts b/app/soapbox/features/emoji/search.ts index c4953eda9..363af2106 100644 --- a/app/soapbox/features/emoji/search.ts +++ b/app/soapbox/features/emoji/search.ts @@ -11,7 +11,6 @@ const index = new Index({ context: true, }); -// console.log(Object.entries(data.emojis)); const sortedEmojis = Object.entries(data.emojis).sort((a, b) => a[0].localeCompare(b[0])); for (const [key, emoji] of sortedEmojis) { index.add('n' + key, `${emoji.id} ${emoji.name} ${emoji.keywords.join(' ')}`); From a2846070c97c5ab6ee7002f1986771a403818158 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 7 Jun 2023 11:35:41 -0500 Subject: [PATCH 51/58] Move .invisible styles to Markup CSS --- app/soapbox/components/markup.css | 18 ++++++++++++++++++ app/styles/ui.scss | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/soapbox/components/markup.css b/app/soapbox/components/markup.css index f451e3ba1..48e292bcc 100644 --- a/app/soapbox/components/markup.css +++ b/app/soapbox/components/markup.css @@ -85,3 +85,21 @@ body.underline-links [data-markup] a { [data-markup] .status-link { @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue; } + +[data-markup] .invisible { + font-size: 0 !important; + line-height: 0 !important; + display: inline-block; + width: 0; + height: 0; + position: absolute; +} + +[data-markup] .invisible img, +[data-markup] .invisible svg { + margin: 0 !important; + border: 0 !important; + padding: 0 !important; + width: 0 !important; + height: 0 !important; +} diff --git a/app/styles/ui.scss b/app/styles/ui.scss index 576d5d4f9..530599d3f 100644 --- a/app/styles/ui.scss +++ b/app/styles/ui.scss @@ -36,24 +36,6 @@ } } -.invisible { - font-size: 0 !important; - line-height: 0 !important; - display: inline-block; - width: 0; - height: 0; - position: absolute; - - img, - svg { - margin: 0 !important; - border: 0 !important; - padding: 0 !important; - width: 0 !important; - height: 0 !important; - } -} - .react-datepicker-popper { z-index: 9999 !important; } From 8d8e4f2ee8aa5310d6fcf4fb356b52687130eb94 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 10 Jun 2023 14:15:34 -0500 Subject: [PATCH 52/58] Don't call verify_credentials twice when an account fails --- CHANGELOG.md | 1 + app/soapbox/actions/auth.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1c73a30..43ca09934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compatibility: fix version parsing for Friendica. - UI: fixed various overflow issues related to long usernames. - UI: fixed display of Markdown code blocks in the reply indicator. +- Auth: fixed too many API requests when the server has an error. ## [3.2.0] - 2023-02-15 diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index e4a10edd0..8c4e550e9 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -178,8 +178,7 @@ export const rememberAuthAccount = (accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) => (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) - .then(() => dispatch(verifyCredentials(token, accountUrl))) - .catch(() => dispatch(verifyCredentials(token, accountUrl))); + .finally(() => dispatch(verifyCredentials(token, accountUrl))); export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { From 263db3e1f0793d384343567ae9e8335ec107d859 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 14:33:19 -0500 Subject: [PATCH 53/58] Improve schemas for Account, EmojiReaction, Location, and Status --- app/soapbox/schemas/account.ts | 181 +++++++++++++++----------- app/soapbox/schemas/emoji-reaction.ts | 2 + app/soapbox/schemas/location.ts | 3 + app/soapbox/schemas/status.ts | 42 +++--- app/soapbox/utils/accounts.ts | 4 +- 5 files changed, 136 insertions(+), 96 deletions(-) diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index f2e5f9c15..f0fdf4887 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -2,122 +2,145 @@ import escapeTextContentForBrowser from 'escape-html'; import z from 'zod'; import emojify from 'soapbox/features/emoji'; +import { unescapeHTML } from 'soapbox/utils/html'; import { customEmojiSchema } from './custom-emoji'; -import { relationshipSchema } from './relationship'; import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); -const accountSchema = z.object({ - accepting_messages: z.boolean().catch(false), - accepts_chat_messages: z.boolean().catch(false), +const birthdaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +const fieldSchema = z.object({ + name: z.string(), + value: z.string(), + verified_at: z.string().datetime().nullable().catch(null), +}); + +const baseAccountSchema = z.object({ acct: z.string().catch(''), avatar: z.string().catch(avatarMissing), - avatar_static: z.string().catch(''), - birthday: z.string().catch(''), + avatar_static: z.string().url().optional().catch(undefined), bot: z.boolean().catch(false), - chats_onboarded: z.boolean().catch(true), created_at: z.string().datetime().catch(new Date().toUTCString()), discoverable: z.boolean().catch(false), display_name: z.string().catch(''), emojis: filteredArray(customEmojiSchema), favicon: z.string().catch(''), - fields: z.any(), // TODO + fields: filteredArray(fieldSchema), followers_count: z.number().catch(0), following_count: z.number().catch(0), - fqn: z.string().catch(''), - header: z.string().catch(headerMissing), - header_static: z.string().catch(''), + fqn: z.string().optional().catch(undefined), + header: z.string().url().catch(headerMissing), + header_static: z.string().url().optional().catch(undefined), id: z.string(), - last_status_at: z.string().catch(''), - location: z.string().catch(''), + last_status_at: z.string().datetime().optional().catch(undefined), + location: z.string().optional().catch(undefined), locked: z.boolean().catch(false), - moved: z.any(), // TODO + moved: z.literal(null).catch(null), mute_expires_at: z.union([ z.string(), z.null(), ]).catch(null), note: contentSchema, - pleroma: z.any(), // TODO - source: z.any(), // TODO + /** Fedibird extra settings. */ + other_settings: z.object({ + birthday: birthdaySchema.nullish().catch(undefined), + location: z.string().optional().catch(undefined), + }).optional().catch(undefined), + pleroma: z.object({ + accepts_chat_messages: z.boolean().catch(false), + accepts_email_list: z.boolean().catch(false), + birthday: birthdaySchema.nullish().catch(undefined), + deactivated: z.boolean().catch(false), + favicon: z.string().url().optional().catch(undefined), + hide_favorites: z.boolean().catch(false), + hide_followers: z.boolean().catch(false), + hide_followers_count: z.boolean().catch(false), + hide_follows: z.boolean().catch(false), + hide_follows_count: z.boolean().catch(false), + is_admin: z.boolean().catch(false), + is_moderator: z.boolean().catch(false), + is_suggested: z.boolean().catch(false), + location: z.string().optional().catch(undefined), + notification_settings: z.object({ + block_from_strangers: z.boolean().catch(false), + }).optional().catch(undefined), + tags: z.array(z.string()).catch([]), + }).optional().catch(undefined), + source: z.object({ + approved: z.boolean().catch(true), + chats_onboarded: z.boolean().catch(true), + fields: filteredArray(fieldSchema), + note: z.string().catch(''), + pleroma: z.object({ + discoverable: z.boolean().catch(true), + }).optional().catch(undefined), + sms_verified: z.boolean().catch(false), + }).optional().catch(undefined), statuses_count: z.number().catch(0), + suspended: z.boolean().catch(false), uri: z.string().url().catch(''), url: z.string().url().catch(''), username: z.string().catch(''), - verified: z.boolean().default(false), + verified: z.boolean().catch(false), website: z.string().catch(''), +}); - /* - * Internal fields - */ - display_name_html: z.string().catch(''), - domain: z.string().catch(''), - note_emojified: z.string().catch(''), - relationship: relationshipSchema.nullable().catch(null), +type BaseAccount = z.infer; +type TransformableAccount = Omit; - /* - * Misc - */ - other_settings: z.any(), -}).transform((account) => { +const getDomain = (url: string) => { + try { + return new URL(url).host; + } catch (e) { + return ''; + } +}; + +/** Add internal fields to the account. */ +const transformAccount = ({ pleroma, other_settings, fields, ...account }: T) => { const customEmojiMap = makeCustomEmojiMap(account.emojis); - // Birthday - const birthday = account.pleroma?.birthday || account.other_settings?.birthday; - account.birthday = birthday; + const newFields = fields.map((field) => ({ + ...field, + name_emojified: emojify(escapeTextContentForBrowser(field.name), customEmojiMap), + value_emojified: emojify(field.value, customEmojiMap), + value_plain: unescapeHTML(field.value), + })); - // Verified - const verified = account.verified === true || account.pleroma?.tags?.includes('verified'); - account.verified = verified; + const domain = getDomain(account.url || account.uri); - // Location - const location = account.location - || account.pleroma?.location - || account.other_settings?.location; - account.location = location; + if (pleroma) { + pleroma.birthday = pleroma.birthday || other_settings?.birthday; + } - // Username - const acct = account.acct || ''; - const username = account.username || ''; - account.username = username || acct.split('@')[0]; + return { + ...account, + admin: pleroma?.is_admin || false, + avatar_static: account.avatar_static || account.avatar, + discoverable: account.discoverable || account.source?.pleroma?.discoverable || false, + display_name: account.display_name.trim().length === 0 ? account.username : account.display_name, + display_name_html: emojify(escapeTextContentForBrowser(account.display_name), customEmojiMap), + domain, + fields: newFields, + fqn: account.fqn || (account.acct.includes('@') ? account.acct : `${account.acct}@${domain}`), + header_static: account.header_static || account.header, + moderator: pleroma?.is_moderator || false, + location: account.location || pleroma?.location || other_settings?.location || '', + note_emojified: emojify(account.note, customEmojiMap), + pleroma, + relationship: undefined, + staff: pleroma?.is_admin || pleroma?.is_moderator || false, + suspended: account.suspended || pleroma?.deactivated || false, + verified: account.verified || pleroma?.tags.includes('verified') || false, + }; +}; - // Display Name - const displayName = account.display_name || ''; - account.display_name = displayName.trim().length === 0 ? account.username : displayName; - account.display_name_html = emojify(escapeTextContentForBrowser(displayName), customEmojiMap); - - // Discoverable - const discoverable = Boolean(account.discoverable || account.source?.pleroma?.discoverable); - account.discoverable = discoverable; - - // Message Acceptance - const acceptsChatMessages = Boolean(account.pleroma?.accepts_chat_messages || account?.accepting_messages); - account.accepts_chat_messages = acceptsChatMessages; - - // Notes - account.note_emojified = emojify(account.note, customEmojiMap); - - /* - * Todo - * - internal fields - * - donor - * - tags - * - fields - * - pleroma legacy fields - * - emojification - * - domain - * - guessFqn - * - fqn - * - favicon - * - staff fields - * - birthday - * - note - */ - - return account; -}); +const accountSchema = baseAccountSchema.extend({ + moved: baseAccountSchema.transform(transformAccount).nullable().catch(null), +}).transform(transformAccount); type Account = z.infer; diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts index 1559148e1..56998c625 100644 --- a/app/soapbox/schemas/emoji-reaction.ts +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -7,6 +7,8 @@ const emojiReactionSchema = z.object({ name: emojiSchema, count: z.number().nullable().catch(null), me: z.boolean().catch(false), + /** Akkoma custom emoji reaction. */ + url: z.string().url().optional().catch(undefined), }); type EmojiReaction = z.infer; diff --git a/app/soapbox/schemas/location.ts b/app/soapbox/schemas/location.ts index cbc237222..a0435f1f3 100644 --- a/app/soapbox/schemas/location.ts +++ b/app/soapbox/schemas/location.ts @@ -12,6 +12,9 @@ const locationSchema = z.object({ origin_provider: z.string().catch(''), type: z.string().catch(''), timezone: z.string().catch(''), + name: z.string().catch(''), + latitude: z.number().catch(0), + longitude: z.number().catch(0), geom: z.object({ coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null), srid: z.string().catch(''), diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index 323ca5789..de06ecb98 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -8,6 +8,7 @@ import { accountSchema } from './account'; import { attachmentSchema } from './attachment'; import { cardSchema } from './card'; import { customEmojiSchema } from './custom-emoji'; +import { emojiReactionSchema } from './emoji-reaction'; import { eventSchema } from './event'; import { groupSchema } from './group'; import { mentionSchema } from './mention'; @@ -15,6 +16,13 @@ import { pollSchema } from './poll'; import { tagSchema } from './tag'; import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; +const statusPleromaSchema = z.object({ + emoji_reactions: filteredArray(emojiReactionSchema), + event: eventSchema.nullish().catch(undefined), + quote: z.literal(null).catch(null), + quote_visible: z.boolean().catch(true), +}); + const baseStatusSchema = z.object({ account: accountSchema, application: z.object({ @@ -40,12 +48,11 @@ 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), + pleroma: statusPleromaSchema.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), @@ -61,7 +68,9 @@ const baseStatusSchema = z.object({ }); type BaseStatus = z.infer; -type TransformableStatus = Omit; +type TransformableStatus = Omit & { + pleroma?: Omit, 'quote'> +}; /** Creates search index from the status. */ const buildSearchIndex = (status: TransformableStatus): string => { @@ -85,7 +94,7 @@ type Translation = { } /** Add internal fields to the status. */ -const transformStatus = (status: T) => { +const transformStatus = ({ pleroma, ...status }: T) => { const emojiMap = makeCustomEmojiMap(status.emojis); const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap)); @@ -93,15 +102,20 @@ const transformStatus = (status: T) => { return { ...status, - contentHtml, - 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, + contentHtml, expectsCard: false, + event: pleroma?.event, + filtered: [], + hidden: false, + pleroma: pleroma ? (() => { + const { event, ...rest } = pleroma; + return rest; + })() : undefined, + search_index: buildSearchIndex(status), + showFiltered: false, // TODO: this should be removed from the schema and done somewhere else + spoilerHtml, + translation: undefined as Translation | undefined, }; }; @@ -113,10 +127,8 @@ const embeddedStatusSchema = baseStatusSchema const statusSchema = baseStatusSchema.extend({ quote: embeddedStatusSchema, reblog: embeddedStatusSchema, - pleroma: z.object({ - event: eventSchema, + pleroma: statusPleromaSchema.extend({ quote: embeddedStatusSchema, - quote_visible: z.boolean().catch(true), }).optional().catch(undefined), }).transform(({ pleroma, ...status }) => { return { diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index 47781ffbf..e0995416f 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -23,8 +23,8 @@ export const getBaseURL = (account: AccountEntity): string => { } }; -export const getAcct = (account: AccountEntity | Account, displayFqn: boolean): string => ( - displayFqn === true ? account.fqn : account.acct +export const getAcct = (account: Pick, displayFqn: boolean): string => ( + displayFqn === true ? account.fqn as string : account.acct ); export const isLocal = (account: AccountEntity | Account): boolean => { From 0b439b79a1013413de7de62730b01794ddbc7350 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 16:25:39 -0500 Subject: [PATCH 54/58] Upgrade to TypeScript 5.1 --- app/soapbox/components/list.tsx | 1 + .../components/ui/form-group/form-group.tsx | 3 +++ .../manage-group-modal/create-group-modal.tsx | 7 +++--- app/soapbox/features/ui/util/fullscreen.ts | 24 +------------------ package.json | 2 +- yarn.lock | 8 +++---- 6 files changed, 14 insertions(+), 31 deletions(-) diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index b56e0e6a7..dad4c972a 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -43,6 +43,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec const isSelect = child.type === SelectDropdown || child.type === Select; return React.cloneElement(child, { + // @ts-ignore id: domId, className: clsx({ 'w-auto': isSelect, diff --git a/app/soapbox/components/ui/form-group/form-group.tsx b/app/soapbox/components/ui/form-group/form-group.tsx index 7efb60ade..431615cde 100644 --- a/app/soapbox/components/ui/form-group/form-group.tsx +++ b/app/soapbox/components/ui/form-group/form-group.tsx @@ -29,9 +29,12 @@ const FormGroup: React.FC = (props) => { if (React.isValidElement(inputChildren[0])) { firstChild = React.cloneElement( inputChildren[0], + // @ts-ignore { id: formFieldId }, ); } + + // @ts-ignore const isCheckboxFormGroup = firstChild?.type === Checkbox; if (isCheckboxFormGroup) { diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx index d87126c74..a05d1bbf1 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx @@ -1,6 +1,7 @@ import { AxiosError } from 'axios'; import React, { useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { z } from 'zod'; import { useCreateGroup, useGroupValidation, type CreateGroupParams } from 'soapbox/api/hooks'; import { Modal, Stack } from 'soapbox/components/ui'; @@ -71,9 +72,9 @@ const CreateGroupModal: React.FC = ({ onClose }) => { }, onError(error) { if (error instanceof AxiosError) { - const msg = error.response?.data.error; - if (typeof msg === 'string') { - toast.error(msg); + const msg = z.object({ error: z.string() }).safeParse(error.response?.data); + if (msg.success) { + toast.error(msg.data.error); } } }, diff --git a/app/soapbox/features/ui/util/fullscreen.ts b/app/soapbox/features/ui/util/fullscreen.ts index 5e13d68cc..b04092f0b 100644 --- a/app/soapbox/features/ui/util/fullscreen.ts +++ b/app/soapbox/features/ui/util/fullscreen.ts @@ -33,26 +33,4 @@ export const requestFullscreen = (el: Element): void => { // @ts-ignore el.mozRequestFullScreen(); } -}; - -type FullscreenListener = (this: Document, ev: Event) => void; - -export const attachFullscreenListener = (listener: FullscreenListener): void => { - if ('onfullscreenchange' in document) { - document.addEventListener('fullscreenchange', listener); - } else if ('onwebkitfullscreenchange' in document) { - document.addEventListener('webkitfullscreenchange', listener); - } else if ('onmozfullscreenchange' in document) { - document.addEventListener('mozfullscreenchange', listener); - } -}; - -export const detachFullscreenListener = (listener: FullscreenListener): void => { - if ('onfullscreenchange' in document) { - document.removeEventListener('fullscreenchange', listener); - } else if ('onwebkitfullscreenchange' in document) { - document.removeEventListener('webkitfullscreenchange', listener); - } else if ('onmozfullscreenchange' in document) { - document.removeEventListener('mozfullscreenchange', listener); - } -}; +}; \ No newline at end of file diff --git a/package.json b/package.json index 78ee0bd67..4abab6c2d 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ "ts-node": "^10.9.1", "tslib": "^2.3.1", "twemoji": "https://github.com/twitter/twemoji#v14.0.2", - "typescript": "^4.4.4", + "typescript": "^5.1.3", "util": "^0.12.4", "uuid": "^9.0.0", "webpack": "^5.72.1", diff --git a/yarn.lock b/yarn.lock index 022c5b8d0..282fca78c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17150,10 +17150,10 @@ typescript@^4.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324" integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA== -typescript@^4.4.4: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.3.tgz#8d84219244a6b40b6fb2b33cc1c062f715b9e826" + integrity sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" From db070150d914be178e1c72e55fa3b7e1c3ae14c4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 20:59:38 -0500 Subject: [PATCH 55/58] Remove unnecessary `as string` --- app/soapbox/utils/accounts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index e0995416f..ef7c446a5 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -24,7 +24,7 @@ export const getBaseURL = (account: AccountEntity): string => { }; export const getAcct = (account: Pick, displayFqn: boolean): string => ( - displayFqn === true ? account.fqn as string : account.acct + displayFqn === true ? account.fqn : account.acct ); export const isLocal = (account: AccountEntity | Account): boolean => { From 60eaf01940bc0fda24dcabcd5316e2d63e6dec43 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 22:12:42 -0500 Subject: [PATCH 56/58] Add Resolve utility type --- app/soapbox/schemas/account.ts | 4 +++- app/soapbox/schemas/status.ts | 4 +++- app/soapbox/utils/types.ts | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 app/soapbox/utils/types.ts diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index f0fdf4887..6c41bebc4 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -7,6 +7,8 @@ import { unescapeHTML } from 'soapbox/utils/html'; import { customEmojiSchema } from './custom-emoji'; import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; +import type { Resolve } from 'soapbox/utils/types'; + const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); @@ -142,6 +144,6 @@ const accountSchema = baseAccountSchema.extend({ moved: baseAccountSchema.transform(transformAccount).nullable().catch(null), }).transform(transformAccount); -type Account = z.infer; +type Account = Resolve>; export { accountSchema, type Account }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index de06ecb98..accd31a6a 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -16,6 +16,8 @@ import { pollSchema } from './poll'; import { tagSchema } from './tag'; import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; +import type { Resolve } from 'soapbox/utils/types'; + const statusPleromaSchema = z.object({ emoji_reactions: filteredArray(emojiReactionSchema), event: eventSchema.nullish().catch(undefined), @@ -144,6 +146,6 @@ const statusSchema = baseStatusSchema.extend({ }; }).transform(transformStatus); -type Status = z.infer; +type Status = Resolve>; export { statusSchema, type Status }; \ No newline at end of file diff --git a/app/soapbox/utils/types.ts b/app/soapbox/utils/types.ts new file mode 100644 index 000000000..31eacd481 --- /dev/null +++ b/app/soapbox/utils/types.ts @@ -0,0 +1,7 @@ +/** + * Resolve a type into a flat POJO interface if it's been wrapped by generics. + * https://gleasonator.com/@alex/posts/AWfK4hyppMDCqrT2y8 + */ +type Resolve = Pick; + +export type { Resolve }; \ No newline at end of file From 590ab73b99c9c04e232b2eb3e1a75aa4d2a66597 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 22:14:28 -0500 Subject: [PATCH 57/58] npx browserslist@latest --update-db --- yarn.lock | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 282fca78c..9458d859a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6534,15 +6534,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001254, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001366: - version "1.0.30001441" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz" - integrity sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg== - -caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001449: - version "1.0.30001450" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001450.tgz#022225b91200589196b814b51b1bbe45144cf74f" - integrity sha512-qMBmvmQmFXaSxexkjjfMvD5rnDL0+m+dUMZKoDYsGG8iZN29RuYh9eRoMvKsT6uMAWlyUUGDEQGJJYjzCIO9ew== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001254, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001366, caniuse-lite@^1.0.30001449: + version "1.0.30001502" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001502.tgz" + integrity sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg== capture-exit@^2.0.0: version "2.0.0" From e3f92eadace3048c4496c5521a5978b41da40561 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 14 Jun 2023 08:05:25 -0400 Subject: [PATCH 58/58] Add Groups to Thumb Navigation --- app/soapbox/components/thumb-navigation.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/thumb-navigation.tsx b/app/soapbox/components/thumb-navigation.tsx index 6abaa084e..013ecece7 100644 --- a/app/soapbox/components/thumb-navigation.tsx +++ b/app/soapbox/components/thumb-navigation.tsx @@ -3,15 +3,17 @@ import { FormattedMessage } from 'react-intl'; import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link'; import { useStatContext } from 'soapbox/contexts/stat-context'; -import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { useAppSelector, useFeatures, useGroupsPath, useOwnAccount } from 'soapbox/hooks'; const ThumbNavigation: React.FC = (): JSX.Element => { const account = useOwnAccount(); + const features = useFeatures(); + const groupsPath = useGroupsPath(); + const { unreadChatsCount } = useStatContext(); const notificationCount = useAppSelector((state) => state.notifications.unread); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); - const features = useFeatures(); /** Conditionally render the supported messages link */ const renderMessagesLink = (): React.ReactNode => { @@ -51,6 +53,15 @@ const ThumbNavigation: React.FC = (): JSX.Element => { exact /> + {features.groups && ( + } + to={groupsPath} + exact + /> + )} + }