Group notifications/reposts fetched from the same page

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
merge-requests/2909/head
marcin mikołajczak 2023-12-30 22:19:32 +01:00
rodzic 6b3b68a131
commit cfa6cda48c
11 zmienionych plików z 187 dodań i 76 usunięć

Wyświetl plik

@ -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) => {

Wyświetl plik

@ -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<string, any> = {}, done: () => any = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp);
@ -240,7 +282,9 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, 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 => {

Wyświetl plik

@ -146,6 +146,27 @@ const parseTags = (tags: Record<string, any[]> = {}, 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<string, any> = {}, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const timeline = getState().timelines.get(timelineId) || {} as Record<string, any>;
@ -172,12 +193,15 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
return api(getState).get(path, { params }).then(response => {
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,

Wyświetl plik

@ -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<IStatus> = (props) => {
/>
);
} else if (isReblog) {
const accounts = status.accounts || ImmutableList([status.account]);
const renderedAccounts = accounts.slice(0, 2).map(account => !!account && (
<Link to={`/@${account.acct}`} className='hover:underline'>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: account.display_name_html,
}}
/>
</bdi>
</Link>
)).toArray().filter(Boolean);
if (accounts.size > 2) {
renderedAccounts.push(
<FormattedMessage
id='notification.more'
defaultMessage='{count, plural, one {# other} other {# others}}'
values={{ count: accounts.size - renderedAccounts.length }}
/>,
);
}
return (
<StatusInfo
avatarSize={avatarSize}
@ -258,18 +284,8 @@ const Status: React.FC<IStatus> = (props) => {
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<Link to={`/@${status.account.acct}`} className='hover:underline'>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: status.account.display_name_html,
}}
/>
</bdi>
</Link>
),
name: <FormattedList type='conjunction' value={renderedAccounts} />,
count: accounts.size,
}}
/>
}

Wyświetl plik

@ -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<NotificationType, string> = {
'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'),
};
const nameMessage = defineMessage({
id: 'notification.name',
defaultMessage: '{link}{others}',
});
const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
follow: {
id: 'notification.follow',
@ -138,26 +135,29 @@ const buildMessage = (
intl: IntlShape,
type: NotificationType,
account: AccountEntity,
totalCount: number | null,
accounts: ImmutableList<AccountEntity> | 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(
<FormattedMessage
id='notification.others'
defaultMessage='+ {count, plural, one {# other} other {# others}}'
values={{ count: totalCount - 1 }}
/>
) : '',
});
id='notification.more'
defaultMessage='{count, plural, one {# other} other {# others}}'
values={{ count: accounts.size - renderedAccounts.length }}
/>,
);
}
return intl.formatMessage(messages[type], {
name,
name: <FormattedList type='conjunction' value={renderedAccounts} />,
targetName,
instance: instanceTitle,
count: accounts.size,
});
};
@ -187,7 +187,7 @@ const Notification: React.FC<INotificaton> = (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<INotificaton> = (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<AccountEntity>, targetName, instance.title)
: null;
const ariaLabel = validType(type) ? (
notificationForScreenReader(

Wyświetl plik

@ -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",

Wyświetl plik

@ -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<Account>,
accounts: null as ImmutableList<EmbeddedEntity<Account>> | null,
chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention
created_at: new Date(),
emoji: null as string | null, // pleroma:emoji_reaction

Wyświetl plik

@ -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<Account> | null,
application: null as ImmutableMap<string, any> | null,
approval_status: 'approved' as StatusApprovalStatus,
bookmarked: false,
@ -265,6 +266,17 @@ const parseAccount = (status: ImmutableMap<string, any>) => {
}
};
const parseAccounts = (status: ImmutableMap<string, any>) => {
try {
if (status.get('accounts')) {
const accounts = status.get('accounts').map((account: ImmutableMap<string, any>) => accountSchema.parse(maybeFromJS(account)));
return status.set('accounts', accounts);
}
} catch (_e) {
return status.set('accounts', null);
}
};
const parseGroup = (status: ImmutableMap<string, any>) => {
try {
const group = groupSchema.parse(status.get('group').toJS());
@ -293,6 +305,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
normalizeDislikes(status);
normalizeTombstone(status);
parseAccount(status);
parseAccounts(status);
parseGroup(status);
}),
);

Wyświetl plik

@ -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,
});

Wyświetl plik

@ -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),

Wyświetl plik

@ -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<string>) {
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,
});
});
};