From 68f66075f244d3bc6b34aa2b16aec78c9f08b219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 27 Oct 2023 17:27:49 +0200 Subject: [PATCH 1/5] Hide hashtag follow toggle when unauthenticated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/features/hashtag-timeline/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/hashtag-timeline/index.tsx b/src/features/hashtag-timeline/index.tsx index f5c21b5b9..27d8212ea 100644 --- a/src/features/hashtag-timeline/index.tsx +++ b/src/features/hashtag-timeline/index.tsx @@ -7,7 +7,7 @@ import { useHashtagStream } from 'soapbox/api/hooks'; import List, { ListItem } from 'soapbox/components/list'; import { Column, Toggle } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures, useLoggedIn } from 'soapbox/hooks'; interface IHashtagTimeline { params?: { @@ -22,7 +22,7 @@ export const HashtagTimeline: React.FC = ({ params }) => { const dispatch = useAppDispatch(); const tag = useAppSelector((state) => state.tags.get(id)); const next = useAppSelector(state => state.timelines.get(`hashtag:${id}`)?.next); - + const { isLoggedIn } = useLoggedIn(); const handleLoadMore = (maxId: string) => { dispatch(expandHashtagTimeline(id, { url: next, maxId })); @@ -50,7 +50,7 @@ export const HashtagTimeline: React.FC = ({ params }) => { return ( - {features.followHashtags && ( + {features.followHashtags && isLoggedIn && ( } From b31df6dcfb3252553552b75b8f0e5bac23f23389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 29 Oct 2023 00:21:28 +0200 Subject: [PATCH 2/5] Support Iceshrimp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/utils/features.ts | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/utils/features.ts b/src/utils/features.ts index b7dadd51b..96f91f9c0 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -14,24 +14,30 @@ const overrides = custom('features'); /** Truthy array convenience function */ const any = (arr: Array): boolean => arr.some(Boolean); -/** - * Firefish, a fork of Misskey. Formerly known as Calckey. - * @see {@link https://joinfirefish.org/} - */ -export const FIREFISH = 'Firefish'; - /** * Ditto, a Nostr server with Mastodon API. * @see {@link https://gitlab.com/soapbox-pub/ditto} */ export const DITTO = 'Ditto'; +/** + * Firefish, a fork of Misskey. Formerly known as Calckey. + * @see {@link https://joinfirefish.org/} + */ +export const FIREFISH = 'Firefish'; + /** * Friendica, decentralized social platform implementing multiple federation protocols. * @see {@link https://friendi.ca/} */ export const FRIENDICA = 'Friendica'; +/** + * Iceshrimp, yet another Misskey fork. + * @see {@link https://iceshrimp.dev/} + */ +export const ICESHRIMP = 'Iceshrimp'; + /** * Mastodon, the software upon which this is all based. * @see {@link https://joinmastodon.org/} @@ -143,6 +149,7 @@ const getInstanceFeatures = (instance: Instance) => { */ accountLookup: any([ v.software === FIREFISH, + v.software === ICESHRIMP, v.software === MASTODON && gte(v.compatVersion, '3.4.0'), v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === TAKAHE && gte(v.version, '0.6.1'), @@ -192,6 +199,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see {@link https://docs.joinmastodon.org/methods/announcements/} */ announcements: any([ + v.software === ICESHRIMP, v.software === MASTODON && gte(v.compatVersion, '3.1.0'), v.software === PLEROMA && gte(v.version, '2.2.49'), v.software === TAKAHE && gte(v.version, '0.7.0'), @@ -230,6 +238,7 @@ const getInstanceFeatures = (instance: Instance) => { */ bookmarks: any([ v.software === FIREFISH, + v.software === ICESHRIMP, v.software === FRIENDICA, v.software === MASTODON && gte(v.compatVersion, '3.1.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), @@ -319,6 +328,7 @@ const getInstanceFeatures = (instance: Instance) => { */ conversations: any([ v.software === FIREFISH, + v.software === ICESHRIMP, v.software === FRIENDICA, v.software === MASTODON && gte(v.compatVersion, '2.6.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), @@ -359,6 +369,7 @@ const getInstanceFeatures = (instance: Instance) => { editProfile: any([ v.software === FIREFISH, v.software === FRIENDICA, + v.software === ICESHRIMP, v.software === MASTODON, v.software === MITRA, v.software === PIXELFED, @@ -374,6 +385,7 @@ const getInstanceFeatures = (instance: Instance) => { */ editStatuses: any([ v.software === FRIENDICA && gte(v.version, '2022.12.0'), + v.software === ICESHRIMP, v.software === MASTODON && gte(v.version, '3.5.0'), v.software === TAKAHE && gte(v.version, '0.8.0'), features.includes('editing'), @@ -444,6 +456,7 @@ const getInstanceFeatures = (instance: Instance) => { exposableReactions: any([ v.software === FIREFISH, v.software === FRIENDICA, + v.software === ICESHRIMP, v.software === MASTODON, v.software === TAKAHE && gte(v.version, '0.6.1'), v.software === TRUTHSOCIAL, @@ -628,6 +641,7 @@ const getInstanceFeatures = (instance: Instance) => { lists: any([ v.software === FIREFISH, v.software === FRIENDICA, + v.software === ICESHRIMP, v.software === MASTODON && gte(v.compatVersion, '2.1.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), ]), @@ -683,6 +697,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see PUT /api/v1/accounts/:id/mute */ mutesDuration: any([ + v.software === ICESHRIMP, v.software === PLEROMA && gte(v.version, '2.3.0'), v.software === MASTODON && gte(v.compatVersion, '3.3.0'), v.software === TAKAHE, @@ -715,6 +730,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see GET /api/v1/notifications */ notificationsIncludeTypes: any([ + v.software === ICESHRIMP, v.software === MASTODON && gte(v.compatVersion, '3.5.0'), v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === TAKAHE && gte(v.version, '0.6.2'), @@ -739,6 +755,7 @@ const getInstanceFeatures = (instance: Instance) => { */ polls: any([ v.software === FIREFISH, + v.software === ICESHRIMP, v.software === MASTODON && gte(v.version, '2.8.0'), v.software === PLEROMA, v.software === TAKAHE && gte(v.version, '0.8.0'), @@ -779,6 +796,7 @@ const getInstanceFeatures = (instance: Instance) => { publicTimeline: any([ v.software === FIREFISH, v.software === FRIENDICA, + v.software === ICESHRIMP, v.software === MASTODON, v.software === PLEROMA, v.software === TAKAHE, @@ -864,6 +882,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v2/search */ searchFromAccount: any([ + v.software === ICESHRIMP, v.software === MASTODON && gte(v.version, '2.8.0'), v.software === PLEROMA && gte(v.version, '1.0.0'), ]), @@ -917,6 +936,7 @@ const getInstanceFeatures = (instance: Instance) => { */ suggestionsV2: any([ v.software === FRIENDICA, + v.software === ICESHRIMP, v.software === MASTODON && gte(v.compatVersion, '3.4.0'), v.software === TRUTHSOCIAL, features.includes('v2_suggestions'), @@ -933,6 +953,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see GET /api/v1/trends/statuses */ trendingStatuses: any([ + v.software === ICESHRIMP, v.software === FRIENDICA && gte(v.version, '2022.12.0'), v.software === MASTODON && gte(v.compatVersion, '3.5.0'), ]), @@ -943,6 +964,7 @@ const getInstanceFeatures = (instance: Instance) => { */ trends: any([ v.software === FRIENDICA && gte(v.version, '2022.12.0'), + v.software === ICESHRIMP, v.software === MASTODON && gte(v.compatVersion, '3.0.0'), v.software === TRUTHSOCIAL, v.software === DITTO, From 1d3424e648bd24ce6bb0bfc483b7eec402bbaccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 29 Oct 2023 23:00:48 +0100 Subject: [PATCH 3/5] Display emoji reactions on glitch-soc and Iceshrimp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/emoji-reacts.ts | 12 +-- src/components/status-action-bar.tsx | 9 +- src/components/status-action-button.tsx | 6 +- src/components/status-reaction-wrapper.tsx | 2 +- .../components/status-interaction-bar.tsx | 18 ++-- src/normalizers/status.ts | 18 +++- src/reducers/statuses.ts | 4 +- src/schemas/instance.ts | 3 + src/schemas/status.ts | 6 +- src/utils/emoji-reacts.test.ts | 64 ++++++-------- src/utils/emoji-reacts.ts | 83 ++++++------------- src/utils/features.ts | 7 ++ 12 files changed, 103 insertions(+), 129 deletions(-) diff --git a/src/actions/emoji-reacts.ts b/src/actions/emoji-reacts.ts index 764ed0139..1e8991824 100644 --- a/src/actions/emoji-reacts.ts +++ b/src/actions/emoji-reacts.ts @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList } from 'immutable'; import { isLoggedIn } from 'soapbox/utils/auth'; @@ -8,7 +8,7 @@ import { importFetchedAccounts, importFetchedStatus } from './importer'; import { favourite, unfavourite } from './interactions'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity, Status } from 'soapbox/types/entities'; +import type { APIEntity, EmojiReaction, Status } from 'soapbox/types/entities'; const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST'; const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS'; @@ -26,17 +26,17 @@ const noOp = () => () => new Promise(f => f(undefined)); const simpleEmojiReact = (status: Status, emoji: string, custom?: string) => (dispatch: AppDispatch) => { - const emojiReacts: ImmutableList> = status.pleroma.get('emoji_reactions') || ImmutableList(); + const emojiReacts: ImmutableList = status.reactions || ImmutableList(); if (emoji === '👍' && status.favourited) return dispatch(unfavourite(status)); - const undo = emojiReacts.filter(e => e.get('me') === true && e.get('name') === emoji).count() > 0; + const undo = emojiReacts.filter(e => e.me === true && e.name === emoji).count() > 0; if (undo) return dispatch(unEmojiReact(status, emoji)); return Promise.all([ ...emojiReacts - .filter((emojiReact) => emojiReact.get('me') === true) - .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))).toArray(), + .filter((emojiReact) => emojiReact.me === true) + .map(emojiReact => dispatch(unEmojiReact(status, emojiReact.name))).toArray(), status.favourited && dispatch(unfavourite(status)), ]).then(() => { if (emoji === '👍') { diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index aad3c1f82..b07550ed7 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -1,4 +1,3 @@ -import { List as ImmutableList } from 'immutable'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useHistory, useRouteMatch } from 'react-router-dom'; @@ -626,15 +625,15 @@ const StatusActionBar: React.FC = ({ const reblogCount = status.reblogs_count; const favouriteCount = status.favourites_count; - const emojiReactCount = reduceEmoji( - (status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList, + const emojiReactCount = status.reactions ? reduceEmoji( + status.reactions, favouriteCount, status.favourited, allowedEmoji, - ).reduce((acc, cur) => acc + cur.get('count'), 0); + ).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined; const meEmojiReact = getReactForStatus(status, allowedEmoji); - const meEmojiName = meEmojiReact?.get('name') as keyof typeof reactMessages | undefined; + const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined; const reactMessages = { '👍': messages.reactionLike, diff --git a/src/components/status-action-button.tsx b/src/components/status-action-button.tsx index 3dfe61939..aa87502ca 100644 --- a/src/components/status-action-button.tsx +++ b/src/components/status-action-button.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Text, Icon, Emoji } from 'soapbox/components/ui'; import { shortNumberFormat } from 'soapbox/utils/numbers'; -import type { Map as ImmutableMap } from 'immutable'; +import type { EmojiReaction } from 'soapbox/schemas'; const COLORS = { accent: 'accent', @@ -33,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes; + emoji?: EmojiReaction; text?: React.ReactNode; theme?: 'default' | 'inverse'; } @@ -45,7 +45,7 @@ const StatusActionButton = React.forwardRef - + ); } else { diff --git a/src/components/status-reaction-wrapper.tsx b/src/components/status-reaction-wrapper.tsx index f8e6129d1..c1d1224de 100644 --- a/src/components/status-reaction-wrapper.tsx +++ b/src/components/status-reaction-wrapper.tsx @@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC = ({ statusId, chi }; const handleClick: React.EventHandler = e => { - const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍'; + const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.name || '👍'; if (isUserTouching()) { if (ownAccount) { diff --git a/src/features/status/components/status-interaction-bar.tsx b/src/features/status/components/status-interaction-bar.tsx index 295d0f170..2e1809a22 100644 --- a/src/features/status/components/status-interaction-bar.tsx +++ b/src/features/status/components/status-interaction-bar.tsx @@ -1,6 +1,4 @@ -import clsx from 'clsx'; -import { List as ImmutableList } from 'immutable'; -import React from 'react'; +import clsx from 'clsx';import React from 'react'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -59,7 +57,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. const getNormalizedReacts = () => { return reduceEmoji( - ImmutableList(status.pleroma.get('emoji_reactions') as any), + status.reactions, status.favourites_count, status.favourited, allowedEmoji, @@ -164,20 +162,22 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. const getEmojiReacts = () => { const emojiReacts = getNormalizedReacts(); const count = emojiReacts.reduce((acc, cur) => ( - acc + cur.get('count') + acc + (cur.count || 0) ), 0); + const handleClick = features.emojiReacts ? handleOpenReactionsModal : handleOpenFavouritesModal; + if (count) { return ( - + {emojiReacts.take(3).map((e, i) => { return ( ); })} @@ -193,7 +193,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. {getReposts()} {getQuotes()} - {features.emojiReacts ? getEmojiReacts() : getFavourites()} + {(features.emojiReacts || features.emojiReactsMastodon) ? getEmojiReacts() : getFavourites()} {getDislikes()} ); diff --git a/src/normalizers/status.ts b/src/normalizers/status.ts index ff1cb26aa..e621f210b 100644 --- a/src/normalizers/status.ts +++ b/src/normalizers/status.ts @@ -13,10 +13,10 @@ import { import { normalizeAttachment } from 'soapbox/normalizers/attachment'; import { normalizeEmoji } from 'soapbox/normalizers/emoji'; import { normalizeMention } from 'soapbox/normalizers/mention'; -import { accountSchema, cardSchema, groupSchema, pollSchema, tombstoneSchema } from 'soapbox/schemas'; +import { accountSchema, cardSchema, emojiReactionSchema, groupSchema, pollSchema, tombstoneSchema } from 'soapbox/schemas'; import { maybeFromJS } from 'soapbox/utils/normalizers'; -import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; +import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity, EmojiReaction } from 'soapbox/types/entities'; export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected'; export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self' | 'group'; @@ -69,6 +69,7 @@ export const StatusRecord = ImmutableRecord({ poll: null as EmbeddedEntity, quote: null as EmbeddedEntity, quotes_count: 0, + reactions: null as ImmutableList | null, reblog: null as EmbeddedEntity, reblogged: false, reblogs_count: 0, @@ -104,8 +105,8 @@ const normalizeMentions = (status: ImmutableMap) => { }); }; -// Normalize emojis -const normalizeEmojis = (entity: ImmutableMap) => { +// Normalize emoji reactions +const normalizeReactions = (entity: ImmutableMap) => { return entity.update('emojis', ImmutableList(), emojis => { return emojis.map(normalizeEmoji); }); @@ -218,6 +219,14 @@ const normalizeEvent = (status: ImmutableMap) => { } }; +// Normalize emojis +const normalizeEmojis = (status: ImmutableMap) => { + const reactions = status.getIn(['pleroma', 'emoji_reactions'], status.get('reactions')) as ImmutableList>; + if (reactions) { + status.set('reactions', ImmutableList(reactions.map(((reaction: ImmutableMap) => emojiReactionSchema.parse(reaction.toJS()))))); + } +}; + /** Rewrite `

` to empty string. */ const fixContent = (status: ImmutableMap) => { if (status.get('content') === '

') { @@ -275,6 +284,7 @@ export const normalizeStatus = (status: Record) => { fixQuote(status); fixSensitivity(status); normalizeEvent(status); + normalizeReactions(status); fixContent(status); normalizeFilterResults(status); normalizeDislikes(status); diff --git a/src/reducers/statuses.ts b/src/reducers/statuses.ts index a2f8941ad..37d7ec2ad 100644 --- a/src/reducers/statuses.ts +++ b/src/reducers/statuses.ts @@ -274,13 +274,13 @@ export default function statuses(state = initialState, action: AnyAction): State case EMOJI_REACT_REQUEST: return state .updateIn( - [action.status.id, 'pleroma', 'emoji_reactions'], + [action.status.id, 'reactions'], emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji, action.custom), ); case UNEMOJI_REACT_REQUEST: return state .updateIn( - [action.status.id, 'pleroma', 'emoji_reactions'], + [action.status.id, 'reactions'], emojiReacts => simulateUnEmojiReact(emojiReacts as any, action.emoji), ); case FAVOURITE_FAIL: diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 4e55a7456..ee647c678 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -49,6 +49,9 @@ const configurationSchema = coerceObject({ max_options: z.number().optional().catch(undefined), min_expiration: z.number().optional().catch(undefined), }), + reactions: coerceObject({ + max_reactions: z.number().catch(0), + }), statuses: coerceObject({ max_characters: z.number().optional().catch(undefined), max_media_attachments: z.number().optional().catch(undefined), diff --git a/src/schemas/status.ts b/src/schemas/status.ts index 01169dcf1..cf66e4e3d 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -19,7 +19,6 @@ import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './ import type { Resolve } from 'soapbox/utils/types'; const statusPleromaSchema = z.object({ - emoji_reactions: filteredArray(emojiReactionSchema), event: eventSchema.nullish().catch(undefined), quote: z.literal(null).catch(null), quote_visible: z.boolean().catch(true), @@ -51,6 +50,7 @@ const baseStatusSchema = z.object({ muted: z.coerce.boolean(), pinned: z.coerce.boolean(), pleroma: statusPleromaSchema.optional().catch(undefined), + reactions: filteredArray(emojiReactionSchema), poll: pollSchema.nullable().catch(null), quote: z.literal(null).catch(null), quotes_count: z.number().catch(0), @@ -131,16 +131,18 @@ const statusSchema = baseStatusSchema.extend({ reblog: embeddedStatusSchema, pleroma: statusPleromaSchema.extend({ quote: embeddedStatusSchema, + emoji_reactions: filteredArray(emojiReactionSchema), }).optional().catch(undefined), }).transform(({ pleroma, ...status }) => { return { ...status, event: pleroma?.event, quote: pleroma?.quote || status.quote || null, + reactions: pleroma?.emoji_reactions || status.reactions || 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; + const { event, quote, emoji_reactions, ...rest } = pleroma; return rest; })() : undefined, }; diff --git a/src/utils/emoji-reacts.test.ts b/src/utils/emoji-reacts.test.ts index 38660fa13..af2c5c34b 100644 --- a/src/utils/emoji-reacts.test.ts +++ b/src/utils/emoji-reacts.test.ts @@ -1,11 +1,11 @@ -import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable'; +import { List as ImmutableList, fromJS } from 'immutable'; import { normalizeStatus } from 'soapbox/normalizers'; +import { emojiReactionSchema } from 'soapbox/schemas'; import { sortEmoji, mergeEmojiFavourites, - oneEmojiPerAccount, reduceEmoji, getReactForStatus, simulateEmojiReact, @@ -23,7 +23,7 @@ const ALLOWED_EMOJI = ImmutableList([ describe('sortEmoji', () => { describe('with an unsorted list of emoji', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 7, 'me': true, 'name': '😃' }, { 'count': 7, 'me': true, 'name': '😯' }, { 'count': 3, 'me': true, 'name': '😢' }, @@ -31,7 +31,7 @@ describe('sortEmoji', () => { { 'count': 20, 'me': true, 'name': '👍' }, { 'count': 7, 'me': true, 'name': '😂' }, { 'count': 15, 'me': true, 'name': '❤' }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); it('sorts the emoji by count', () => { expect(sortEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([ { 'count': 20, 'me': true, 'name': '👍' }, @@ -51,11 +51,11 @@ describe('mergeEmojiFavourites', () => { const favourited = true; describe('with existing 👍 reacts', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 20, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 15, 'me': false, 'name': '❤', 'url': undefined }, { 'count': 7, 'me': false, 'name': '😯', 'url': undefined }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); it('combines 👍 reacts with favourites', () => { expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([ { 'count': 32, 'me': true, 'name': '👍', 'url': undefined }, @@ -66,10 +66,10 @@ describe('mergeEmojiFavourites', () => { }); describe('without existing 👍 reacts', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 15, 'me': false, 'name': '❤' }, { 'count': 7, 'me': false, 'name': '😯' }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); it('adds 👍 reacts to the map equaling favourite count', () => { expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([ { 'count': 15, 'me': false, 'name': '❤' }, @@ -88,7 +88,7 @@ describe('mergeEmojiFavourites', () => { describe('reduceEmoji', () => { describe('with a clusterfuck of emoji', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 1, 'me': false, 'name': '😡' }, { 'count': 1, 'me': true, 'name': '🔪' }, { 'count': 7, 'me': true, 'name': '😯' }, @@ -99,7 +99,7 @@ describe('reduceEmoji', () => { { 'count': 15, 'me': true, 'name': '❤' }, { 'count': 1, 'me': false, 'name': '👀' }, { 'count': 1, 'me': false, 'name': '🍩' }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); it('sorts, filters, and combines emoji and favourites', () => { expect(reduceEmoji(emojiReacts, 7, true, ALLOWED_EMOJI)).toEqual(fromJS([ { 'count': 27, 'me': true, 'name': '👍' }, @@ -117,22 +117,6 @@ describe('reduceEmoji', () => { }); }); -describe('oneEmojiPerAccount', () => { - it('reduces to one react per account', () => { - const emojiReacts = fromJS([ - // Sorted - { 'count': 2, 'me': true, 'name': '👍', accounts: [{ id: '1' }, { id: '2' }] }, - { 'count': 2, 'me': true, 'name': '❤', accounts: [{ id: '1' }, { id: '2' }] }, - { 'count': 1, 'me': true, 'name': '😯', accounts: [{ id: '1' }] }, - { 'count': 1, 'me': false, 'name': '😂', accounts: [{ id: '3' }] }, - ]) as ImmutableList>; - expect(oneEmojiPerAccount(emojiReacts, '1')).toEqual(fromJS([ - { 'count': 2, 'me': true, 'name': '👍', accounts: [{ id: '1' }, { id: '2' }] }, - { 'count': 1, 'me': false, 'name': '😂', accounts: [{ id: '3' }] }, - ])); - }); -}); - describe('getReactForStatus', () => { it('returns a single owned react (including favourite) for the status', () => { const status = normalizeStatus(fromJS({ @@ -146,12 +130,12 @@ describe('getReactForStatus', () => { ], }, })); - expect(getReactForStatus(status, ALLOWED_EMOJI)?.get('name')).toEqual('❤'); + expect(getReactForStatus(status, ALLOWED_EMOJI)?.name).toEqual('❤'); }); it('returns a thumbs-up for a favourite', () => { const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true })); - expect(getReactForStatus(status)?.get('name')).toEqual('👍'); + expect(getReactForStatus(status)?.name).toEqual('👍'); }); it('returns undefined when a status has no reacts (or favourites)', () => { @@ -172,10 +156,10 @@ describe('getReactForStatus', () => { describe('simulateEmojiReact', () => { it('adds the emoji to the list', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([ { 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 3, 'me': true, 'name': '❤', 'url': undefined }, @@ -183,10 +167,10 @@ describe('simulateEmojiReact', () => { }); it('creates the emoji if it didn\'t already exist', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([ { 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined }, @@ -195,10 +179,10 @@ describe('simulateEmojiReact', () => { }); it('adds a custom emoji to the list', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); expect(simulateEmojiReact(emojiReacts, 'soapbox', 'https://gleasonator.com/emoji/Gleasonator/soapbox.png')).toEqual(fromJS([ { 'count': 2, 'me': false, 'name': '👍', 'url': undefined }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined }, @@ -209,10 +193,10 @@ describe('simulateEmojiReact', () => { describe('simulateUnEmojiReact', () => { it('removes the emoji from the list', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 2, 'me': false, 'name': '👍' }, { 'count': 3, 'me': true, 'name': '❤' }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); expect(simulateUnEmojiReact(emojiReacts, '❤')).toEqual(fromJS([ { 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '❤' }, @@ -220,11 +204,11 @@ describe('simulateUnEmojiReact', () => { }); it('removes the emoji if it\'s the last one in the list', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '❤' }, { 'count': 1, 'me': true, 'name': '😯' }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); expect(simulateUnEmojiReact(emojiReacts, '😯')).toEqual(fromJS([ { 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '❤' }, @@ -232,11 +216,11 @@ describe('simulateUnEmojiReact', () => { }); it ('removes custom emoji from the list', () => { - const emojiReacts = fromJS([ + const emojiReacts = ImmutableList([ { 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '❤' }, { 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' }, - ]) as ImmutableList>; + ].map((react) => emojiReactionSchema.parse(react))); expect(simulateUnEmojiReact(emojiReacts, 'soapbox')).toEqual(fromJS([ { 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '❤' }, diff --git a/src/utils/emoji-reacts.ts b/src/utils/emoji-reacts.ts index 2fa83906b..559644bd7 100644 --- a/src/utils/emoji-reacts.ts +++ b/src/utils/emoji-reacts.ts @@ -1,9 +1,6 @@ -import { - Map as ImmutableMap, - List as ImmutableList, -} from 'immutable'; +import { List as ImmutableList } from 'immutable'; -import type { Me } from 'soapbox/types/soapbox'; +import { EmojiReaction, emojiReactionSchema } from 'soapbox/schemas'; // https://emojipedia.org/facebook // I've customized them. @@ -16,18 +13,16 @@ export const ALLOWED_EMOJI = ImmutableList([ '😩', ]); -type Account = ImmutableMap; -type EmojiReact = ImmutableMap; - -export const sortEmoji = (emojiReacts: ImmutableList, allowedEmoji: ImmutableList): ImmutableList => ( +export const sortEmoji = (emojiReacts: ImmutableList, allowedEmoji: ImmutableList): ImmutableList => ( emojiReacts .sortBy(emojiReact => - -(emojiReact.get('count') + Number(allowedEmoji.includes(emojiReact.get('name'))))) + -((emojiReact.count || 0) + Number(allowedEmoji.includes(emojiReact.name)))) ); -export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), favouritesCount: number, favourited: boolean) => { +export const mergeEmojiFavourites = (emojiReacts: ImmutableList | null, favouritesCount: number, favourited: boolean) => { + if (!emojiReacts) return ImmutableList([emojiReactionSchema.parse({ count: favouritesCount, me: favourited, name: '👍' })]); if (!favouritesCount) return emojiReacts; - const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍'); + const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.name === '👍'); if (likeIndex > -1) { const likeCount = Number(emojiReacts.getIn([likeIndex, 'count'])); favourited = favourited || Boolean(emojiReacts.getIn([likeIndex, 'me'], false)); @@ -35,69 +30,43 @@ export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), .setIn([likeIndex, 'count'], likeCount + favouritesCount) .setIn([likeIndex, 'me'], favourited); } else { - return emojiReacts.push(ImmutableMap({ count: favouritesCount, me: favourited, name: '👍' })); + return emojiReacts.push(emojiReactionSchema.parse({ count: favouritesCount, me: favourited, name: '👍' })); } }; -const hasMultiReactions = (emojiReacts: ImmutableList, account: Account): boolean => ( - emojiReacts.filter( - e => e.get('accounts').filter( - (a: Account) => a.get('id') === account.get('id'), - ).count() > 0, - ).count() > 1 -); - -const inAccounts = (accounts: ImmutableList, id: string): boolean => ( - accounts.filter(a => a.get('id') === id).count() > 0 -); - -export const oneEmojiPerAccount = (emojiReacts: ImmutableList, me: Me) => { - emojiReacts = emojiReacts.reverse(); - - return emojiReacts.reduce((acc, cur, idx) => { - const accounts = cur.get('accounts', ImmutableList()) - .filter((a: Account) => !hasMultiReactions(acc, a)); - - return acc.set(idx, cur.merge({ - accounts: accounts, - count: accounts.count(), - me: me ? inAccounts(accounts, me) : false, - })); - }, emojiReacts) - .filter(e => e.get('count') > 0) - .reverse(); -}; - -export const reduceEmoji = (emojiReacts: ImmutableList, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList => ( +export const reduceEmoji = (emojiReacts: ImmutableList | null, favouritesCount: number, favourited: boolean, allowedEmoji = ALLOWED_EMOJI): ImmutableList => ( sortEmoji( mergeEmojiFavourites(emojiReacts, favouritesCount, favourited), allowedEmoji, )); -export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReact | undefined => { +export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReaction | undefined => { + if (!status.reactions) return; + const result = reduceEmoji( - status.pleroma.get('emoji_reactions', ImmutableList()), + status.reactions, status.favourites_count || 0, status.favourited, allowedEmoji, - ).filter(e => e.get('me') === true) + ).filter(e => e.me === true) .get(0); - return typeof result?.get('name') === 'string' ? result : undefined; + return typeof result?.name === 'string' ? result : undefined; }; -export const simulateEmojiReact = (emojiReacts: ImmutableList, emoji: string, url?: string) => { - const idx = emojiReacts.findIndex(e => e.get('name') === emoji); +export const simulateEmojiReact = (emojiReacts: ImmutableList, emoji: string, url?: string) => { + const idx = emojiReacts.findIndex(e => e.name === emoji); const emojiReact = emojiReacts.get(idx); if (idx > -1 && emojiReact) { - return emojiReacts.set(idx, emojiReact.merge({ - count: emojiReact.get('count') + 1, + return emojiReacts.set(idx, emojiReactionSchema.parse({ + ...emojiReact, + count: (emojiReact.count || 0) + 1, me: true, url, })); } else { - return emojiReacts.push(ImmutableMap({ + return emojiReacts.push(emojiReactionSchema.parse({ count: 1, me: true, name: emoji, @@ -106,17 +75,17 @@ export const simulateEmojiReact = (emojiReacts: ImmutableList, emoji } }; -export const simulateUnEmojiReact = (emojiReacts: ImmutableList, emoji: string) => { +export const simulateUnEmojiReact = (emojiReacts: ImmutableList, emoji: string) => { const idx = emojiReacts.findIndex(e => - e.get('name') === emoji && e.get('me') === true); + e.name === emoji && e.me === true); const emojiReact = emojiReacts.get(idx); if (emojiReact) { - const newCount = emojiReact.get('count') - 1; + const newCount = (emojiReact.count || 1) - 1; if (newCount < 1) return emojiReacts.delete(idx); - return emojiReacts.set(idx, emojiReact.merge({ - count: emojiReact.get('count') - 1, + return emojiReacts.set(idx, emojiReactionSchema.parse({ + count: (emojiReact.count || 1) - 1, me: false, })); } else { diff --git a/src/utils/features.ts b/src/utils/features.ts index 96f91f9c0..d2aa11b2b 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -418,6 +418,13 @@ const getInstanceFeatures = (instance: Instance) => { */ emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'), + /** + * Ability to add emoji reactions to a status available in Mastodon forks. + * @see POST /v1/statuses/:id/react/:emoji + * @see POST /v1/statuses/:id/unreact/:emoji + */ + emojiReactsMastodon: instance.configuration.reactions.max_reactions > 0, + /** * The backend allows only non-RGI ("Recommended for General Interchange") emoji reactions. * @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji From af41877a4e09f93624810664de50bc62e782af90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 31 Oct 2023 20:13:55 +0100 Subject: [PATCH 4/5] Always display poll option title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/components/polls/poll-option.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/polls/poll-option.tsx b/src/components/polls/poll-option.tsx index f27a81e64..760945ff9 100644 --- a/src/components/polls/poll-option.tsx +++ b/src/components/polls/poll-option.tsx @@ -122,7 +122,7 @@ const PollOption: React.FC = (props): JSX.Element | null => { return (
{showResults ? ( -
+
Date: Wed, 1 Nov 2023 23:30:32 +0100 Subject: [PATCH 5/5] Fix federation modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/mrf.ts | 8 ++++---- .../ui/components/modals/edit-federation-modal.tsx | 11 ++++++----- src/utils/config-db.ts | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/actions/mrf.ts b/src/actions/mrf.ts index 359d7711f..509bdb8fc 100644 --- a/src/actions/mrf.ts +++ b/src/actions/mrf.ts @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; +import { Set as ImmutableSet } from 'immutable'; import ConfigDB from 'soapbox/utils/config-db'; @@ -7,9 +7,9 @@ import { fetchConfig, updateConfig } from './admin'; import type { MRFSimple } from 'soapbox/schemas/pleroma'; import type { AppDispatch, RootState } from 'soapbox/store'; -const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: ImmutableMap) => { +const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: Record) => { const entries = Object.entries(simplePolicy).map(([key, hosts]) => { - const isRestricted = restrictions.get(key); + const isRestricted = restrictions[key]; if (isRestricted) { return [key, ImmutableSet(hosts).add(host).toJS()]; @@ -21,7 +21,7 @@ const simplePolicyMerge = (simplePolicy: MRFSimple, host: string, restrictions: return Object.fromEntries(entries); }; -const updateMrf = (host: string, restrictions: ImmutableMap) => +const updateMrf = (host: string, restrictions: Record) => (dispatch: AppDispatch, getState: () => RootState) => dispatch(fetchConfig()) .then(() => { diff --git a/src/features/ui/components/modals/edit-federation-modal.tsx b/src/features/ui/components/modals/edit-federation-modal.tsx index 4b102e8ef..646439612 100644 --- a/src/features/ui/components/modals/edit-federation-modal.tsx +++ b/src/features/ui/components/modals/edit-federation-modal.tsx @@ -30,24 +30,25 @@ const EditFederationModal: React.FC = ({ host, onClose }) const getRemoteInstance = useCallback(makeGetRemoteInstance(), []); const remoteInstance = useAppSelector(state => getRemoteInstance(state, host)); - const [data, setData] = useState({} as any); + const [data, setData] = useState>({}); useEffect(() => { - setData(remoteInstance.get('federation')); + setData(remoteInstance.get('federation') as Record); }, [remoteInstance]); const handleDataChange = (key: string): React.ChangeEventHandler => { return ({ target }) => { - setData(data.set(key, target.checked)); + setData({ ...data, [key]: target.checked }); }; }; const handleMediaRemoval: React.ChangeEventHandler = ({ target: { checked } }) => { - const newData = data.merge({ + const newData = { + ...data, avatar_removal: checked, banner_removal: checked, media_removal: checked, - }); + }; setData(newData); }; diff --git a/src/utils/config-db.ts b/src/utils/config-db.ts index 5faf18163..4dbf3c163 100644 --- a/src/utils/config-db.ts +++ b/src/utils/config-db.ts @@ -9,7 +9,7 @@ import trimStart from 'lodash/trimStart'; import { type MRFSimple, mrfSimpleSchema } from 'soapbox/schemas/pleroma'; export type Config = ImmutableMap; -export type Policy = ImmutableMap; +export type Policy = Record; const find = ( configs: ImmutableList, @@ -40,15 +40,15 @@ const toSimplePolicy = (configs: ImmutableList): MRFSimple => { }; const fromSimplePolicy = (simplePolicy: Policy): ImmutableList => { - const mapper = (hosts: ImmutableList, key: string) => fromJS({ tuple: [`:${key}`, hosts.toJS()] }); + const mapper = ([key, hosts]: [key: string, hosts: ImmutableList]) => fromJS({ tuple: [`:${key}`, hosts] }); - const value = simplePolicy.map(mapper).toList(); + const value = Object.entries(simplePolicy).map(mapper); return ImmutableList([ ImmutableMap({ group: ':pleroma', key: ':mrf_simple', - value, + value: ImmutableList(value), }), ]); };