From cfa6cda48c8c08b303377eb50e911cde72812c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 30 Dec 2023 22:19:32 +0100 Subject: [PATCH] Group notifications/reposts fetched from the same page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/importer/index.ts | 73 ++++++++++--------- src/actions/notifications.ts | 48 +++++++++++- src/actions/timelines.ts | 26 ++++++- src/components/status.tsx | 42 +++++++---- .../notifications/components/notification.tsx | 44 +++++------ src/locales/en.json | 4 +- src/normalizers/notification.ts | 2 + src/normalizers/status.ts | 13 ++++ src/reducers/notifications.ts | 1 + src/schemas/status.ts | 1 + src/selectors/index.ts | 9 ++- 11 files changed, 187 insertions(+), 76 deletions(-) diff --git a/src/actions/importer/index.ts b/src/actions/importer/index.ts index 5afb880c0..ce4e45ba7 100644 --- a/src/actions/importer/index.ts +++ b/src/actions/importer/index.ts @@ -151,47 +151,50 @@ const isBroken = (status: APIEntity) => { } }; -const importFetchedStatuses = (statuses: APIEntity[]) => - (dispatch: AppDispatch, getState: () => RootState) => { - const accounts: APIEntity[] = []; - const normalStatuses: APIEntity[] = []; - const polls: APIEntity[] = []; +const importFetchedStatuses = (statuses: APIEntity[]) => (dispatch: AppDispatch) => { + const accounts: APIEntity[] = []; + const normalStatuses: APIEntity[] = []; + const polls: APIEntity[] = []; - function processStatus(status: APIEntity) { - // Skip broken statuses - if (isBroken(status)) return; + function processStatus(status: APIEntity) { + // Skip broken statuses + if (isBroken(status)) return; - normalStatuses.push(status); - accounts.push(status.account); + normalStatuses.push(status); - if (status.reblog?.id) { - processStatus(status.reblog); - } - - // Fedibird quotes - if (status.quote?.id) { - processStatus(status.quote); - } - - if (status.pleroma?.quote?.id) { - processStatus(status.pleroma.quote); - } - - if (status.poll?.id) { - polls.push(status.poll); - } - - if (status.group?.id) { - dispatch(importFetchedGroup(status.group)); - } + accounts.push(status.account); + if (status.accounts) { + accounts.push(...status.accounts); } - statuses.forEach(processStatus); + if (status.reblog?.id) { + processStatus(status.reblog); + } - dispatch(importPolls(polls)); - dispatch(importFetchedAccounts(accounts)); - dispatch(importStatuses(normalStatuses)); - }; + // Fedibird quotes + if (status.quote?.id) { + processStatus(status.quote); + } + + if (status.pleroma?.quote?.id) { + processStatus(status.pleroma.quote); + } + + if (status.poll?.id) { + polls.push(status.poll); + } + + if (status.group?.id) { + dispatch(importFetchedGroup(status.group)); + } + } + + statuses.forEach(processStatus); + + dispatch(importPolls(polls)); + dispatch(importFetchedAccounts(accounts)); + dispatch(importStatuses(normalStatuses)); +}; const importFetchedPoll = (poll: APIEntity) => (dispatch: AppDispatch) => { diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index c6ce8043a..2957bad8e 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -46,7 +46,7 @@ const NOTIFICATIONS_MARK_READ_FAIL = 'NOTIFICATIONS_MARK_READ_FAIL'; const MAX_QUEUED_NOTIFICATIONS = 40; defineMessages({ - mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, + mention: { id: 'notification.mentioned', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' }, }); @@ -175,6 +175,48 @@ const excludeTypesFromFilter = (filter: string) => { const noOp = () => new Promise(f => f(undefined)); +const STATUS_NOTIFICATION_TYPES = [ + 'favourite', + 'group_favourite', + 'mention', + 'reblog', + 'group_reblog', + 'status', + 'poll', + 'update', + // WIP separate notifications for each reaction? + // 'pleroma:emoji_reaction', + 'pleroma:event_reminder', + 'pleroma:participation_accepted', + 'pleroma:participation_request', +]; + +const deduplicateNotifications = (notifications: any[]) => { + const deduplicatedNotifications: any[] = []; + + for (const notification of notifications) { + if (STATUS_NOTIFICATION_TYPES.includes(notification.type)) { + const existingNotification = deduplicatedNotifications + .find(deduplicatedNotification => deduplicatedNotification.type === notification.type && deduplicatedNotification.status?.id === notification.status?.id); + + if (existingNotification) { + if (existingNotification?.accounts) { + existingNotification.accounts.push(notification.account); + } else { + existingNotification.accounts = [existingNotification.account, notification.account]; + } + existingNotification.id += ':' + notification.id; + } else { + deduplicatedNotifications.push(notification); + } + } else { + deduplicatedNotifications.push(notification); + } + } + + return deduplicatedNotifications; +}; + const expandNotifications = ({ maxId }: Record = {}, done: () => any = noOp) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return dispatch(noOp); @@ -240,7 +282,9 @@ const expandNotifications = ({ maxId }: Record = {}, done: () => an const statusesFromGroups = (Object.values(entries.statuses) as Status[]).filter((status) => !!status.group); dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); + const deduplicatedNotifications = deduplicateNotifications(response.data); + + dispatch(expandNotificationsSuccess(deduplicatedNotifications, next ? next.uri : null, isLoadingMore)); fetchRelatedRelationships(dispatch, response.data); done(); }).catch(error => { diff --git a/src/actions/timelines.ts b/src/actions/timelines.ts index 35f0f5638..ebec31ed4 100644 --- a/src/actions/timelines.ts +++ b/src/actions/timelines.ts @@ -146,6 +146,27 @@ const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none }); }; +const deduplicateStatuses = (statuses: any[]) => { + const deduplicatedStatuses: any[] = []; + + for (const status of statuses) { + const reblogged = status.reblog && deduplicatedStatuses.find((deduplicatedStatuses) => deduplicatedStatuses.reblog?.id === status.reblog.id); + + if (reblogged) { + if (reblogged.accounts) { + reblogged.accounts.push(status.account); + } else { + reblogged.accounts = [reblogged.account, status.account]; + } + reblogged.id += ':' + status.id; + } else { + deduplicatedStatuses.push(status); + } + } + + return deduplicatedStatuses; +}; + const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => (dispatch: AppDispatch, getState: () => RootState) => { const timeline = getState().timelines.get(timelineId) || {} as Record; @@ -172,12 +193,15 @@ const expandTimeline = (timelineId: string, path: string, params: Record { dispatch(importFetchedStatuses(response.data)); + const statuses = deduplicateStatuses(response.data); + dispatch(importFetchedStatuses(statuses.filter(status => status.accounts))); + const statusesFromGroups = (response.data as Status[]).filter((status) => !!status.group); dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); dispatch(expandTimelineSuccess( timelineId, - response.data, + statuses, getNextLink(response), getPrevLink(response), response.status === 206, diff --git a/src/components/status.tsx b/src/components/status.tsx index f226a8429..cedf66373 100644 --- a/src/components/status.tsx +++ b/src/components/status.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; +import { List as ImmutableList } from 'immutable'; import React, { useEffect, useRef, useState } from 'react'; -import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { defineMessages, useIntl, FormattedList, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; @@ -249,6 +250,31 @@ const Status: React.FC = (props) => { /> ); } else if (isReblog) { + const accounts = status.accounts || ImmutableList([status.account]); + + const renderedAccounts = accounts.slice(0, 2).map(account => !!account && ( + + + + + + )).toArray().filter(Boolean); + + if (accounts.size > 2) { + renderedAccounts.push( + , + ); + } + return ( = (props) => { id='status.reblogged_by' defaultMessage='{name} reposted' values={{ - name: ( - - - - - - ), + name: , + count: accounts.size, }} /> } diff --git a/src/features/notifications/components/notification.tsx b/src/features/notifications/components/notification.tsx index f027cddda..41e7b44fa 100644 --- a/src/features/notifications/components/notification.tsx +++ b/src/features/notifications/components/notification.tsx @@ -1,5 +1,6 @@ +import { List as ImmutableList } from 'immutable'; import React, { useCallback } from 'react'; -import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor, defineMessage } from 'react-intl'; +import { defineMessages, useIntl, FormattedList, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import { mentionCompose } from 'soapbox/actions/compose'; @@ -17,7 +18,8 @@ import { makeGetNotification } from 'soapbox/selectors'; import { NotificationType, validType } from 'soapbox/utils/notification'; import type { ScrollPosition } from 'soapbox/components/status'; -import type { Account as AccountEntity, Status as StatusEntity, Notification as NotificationEntity } from 'soapbox/types/entities'; +import type { Account as AccountEntity, Status as StatusEntity, Notification as NotificationEntity, +} from 'soapbox/types/entities'; const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => { const output = [message]; @@ -58,11 +60,6 @@ const icons: Record = { 'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'), }; -const nameMessage = defineMessage({ - id: 'notification.name', - defaultMessage: '{link}{others}', -}); - const messages: Record = defineMessages({ follow: { id: 'notification.follow', @@ -138,26 +135,29 @@ const buildMessage = ( intl: IntlShape, type: NotificationType, account: AccountEntity, - totalCount: number | null, + accounts: ImmutableList | null, targetName: string, instanceTitle: string, ): React.ReactNode => { - const link = buildLink(account); - const name = intl.formatMessage(nameMessage, { - link, - others: totalCount && totalCount > 0 ? ( + if (!accounts) accounts = accounts || ImmutableList([account]); + + const renderedAccounts = accounts.slice(0, 2).map(account => buildLink(account)).toArray().filter(Boolean); + + if (accounts.size > 2) { + renderedAccounts.push( - ) : '', - }); + id='notification.more' + defaultMessage='{count, plural, one {# other} other {# others}}' + values={{ count: accounts.size - renderedAccounts.length }} + />, + ); + } return intl.formatMessage(messages[type], { - name, + name: , targetName, instance: instanceTitle, + count: accounts.size, }); }; @@ -187,7 +187,7 @@ const Notification: React.FC = (props) => { const instance = useInstance(); const type = notification.type; - const { account, status } = notification; + const { account, accounts, status } = notification; const getHandlers = () => ({ reply: handleMention, @@ -356,7 +356,9 @@ const Notification: React.FC = (props) => { const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : ''; - const message: React.ReactNode = validType(type) && account && typeof account === 'object' ? buildMessage(intl, type, account, notification.total_count, targetName, instance.title) : null; + const message: React.ReactNode = validType(type) && account && typeof account === 'object' + ? buildMessage(intl, type, account, accounts as ImmutableList, targetName, instance.title) + : null; const ariaLabel = validType(type) ? ( notificationForScreenReader( diff --git a/src/locales/en.json b/src/locales/en.json index 3299df485..b6986e06b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1069,11 +1069,9 @@ "notification.follow_request": "{name} has requested to follow you", "notification.group_favourite": "{name} liked your group post", "notification.group_reblog": "{name} reposted your group post", - "notification.mention": "{name} mentioned you", "notification.mentioned": "{name} mentioned you", + "notification.more": "{count, plural, one {# other} other {# others}}", "notification.move": "{name} moved to {targetName}", - "notification.name": "{link}{others}", - "notification.others": "+ {count, plural, one {# other} other {# others}}", "notification.pleroma:chat_mention": "{name} sent you a message", "notification.pleroma:emoji_reaction": "{name} reacted to your post", "notification.pleroma:event_reminder": "An event you are participating in starts soon", diff --git a/src/normalizers/notification.ts b/src/normalizers/notification.ts index 45eb93fb3..ab76c976f 100644 --- a/src/normalizers/notification.ts +++ b/src/normalizers/notification.ts @@ -4,6 +4,7 @@ * @see {@link https://docs.joinmastodon.org/entities/notification/} */ import { + List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS, @@ -14,6 +15,7 @@ import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities'; // https://docs.joinmastodon.org/entities/notification/ export const NotificationRecord = ImmutableRecord({ account: null as EmbeddedEntity, + accounts: null as ImmutableList> | null, chat_message: null as ImmutableMap | string | null, // pleroma:chat_mention created_at: new Date(), emoji: null as string | null, // pleroma:emoji_reaction diff --git a/src/normalizers/status.ts b/src/normalizers/status.ts index 9ae7933bc..70ed48a4d 100644 --- a/src/normalizers/status.ts +++ b/src/normalizers/status.ts @@ -44,6 +44,7 @@ interface Tombstone { // https://docs.joinmastodon.org/entities/status/ export const StatusRecord = ImmutableRecord({ account: null as unknown as Account, + accounts: null as ImmutableList | null, application: null as ImmutableMap | null, approval_status: 'approved' as StatusApprovalStatus, bookmarked: false, @@ -265,6 +266,17 @@ const parseAccount = (status: ImmutableMap) => { } }; +const parseAccounts = (status: ImmutableMap) => { + try { + if (status.get('accounts')) { + const accounts = status.get('accounts').map((account: ImmutableMap) => accountSchema.parse(maybeFromJS(account))); + return status.set('accounts', accounts); + } + } catch (_e) { + return status.set('accounts', null); + } +}; + const parseGroup = (status: ImmutableMap) => { try { const group = groupSchema.parse(status.get('group').toJS()); @@ -293,6 +305,7 @@ export const normalizeStatus = (status: Record) => { normalizeDislikes(status); normalizeTombstone(status); parseAccount(status); + parseAccounts(status); parseGroup(status); }), ); diff --git a/src/reducers/notifications.ts b/src/reducers/notifications.ts index f2147b2cf..5c7ea011f 100644 --- a/src/reducers/notifications.ts +++ b/src/reducers/notifications.ts @@ -71,6 +71,7 @@ const comparator = (a: NotificationRecord, b: NotificationRecord) => { const minifyNotification = (notification: NotificationRecord) => { return notification.mergeWith((o, n) => n || o, { account: notification.getIn(['account', 'id']) as string, + accounts: notification.accounts?.map((account: any) => account.get('id')), target: notification.getIn(['target', 'id']) as string, status: notification.getIn(['status', 'id']) as string, }); diff --git a/src/schemas/status.ts b/src/schemas/status.ts index cf66e4e3d..885a61746 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -26,6 +26,7 @@ const statusPleromaSchema = z.object({ const baseStatusSchema = z.object({ account: accountSchema, + accounts: z.array(accountSchema), application: z.object({ name: z.string(), website: z.string().url().nullable().catch(null), diff --git a/src/selectors/index.ts b/src/selectors/index.ts index f569bb070..0230c7b03 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -28,6 +28,10 @@ export function selectAccount(state: RootState, accountId: string) { return state.entities[Entities.ACCOUNTS]?.store[accountId] as AccountSchema | undefined; } +export function selectAccounts(state: RootState, accountIds: ImmutableList) { + return accountIds.map(accountId => state.entities[Entities.ACCOUNTS]?.store[accountId] as AccountSchema | undefined); +} + export function selectOwnAccount(state: RootState) { if (state.me) { return selectAccount(state, state.me); @@ -162,7 +166,8 @@ export const makeGetNotification = () => { (state: RootState, notification: Notification) => selectAccount(state, normalizeId(notification.account)), (state: RootState, notification: Notification) => selectAccount(state, normalizeId(notification.target)), (state: RootState, notification: Notification) => state.statuses.get(normalizeId(notification.status)), - ], (notification, account, target, status) => { + (state: RootState, notification: Notification) => notification.accounts ? selectAccounts(state, notification.accounts?.map(normalizeId)) : null, + ], (notification, account, target, status, accounts) => { return notification.merge({ // @ts-ignore account: account || null, @@ -170,6 +175,8 @@ export const makeGetNotification = () => { target: target || null, // @ts-ignore status: status || null, + // @ts-ignore + accounts, }); }); };