kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Group notifications/reposts fetched from the same page
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>merge-requests/2909/head
rodzic
6b3b68a131
commit
cfa6cda48c
|
@ -151,8 +151,7 @@ const isBroken = (status: APIEntity) => {
|
|||
}
|
||||
};
|
||||
|
||||
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importFetchedStatuses = (statuses: APIEntity[]) => (dispatch: AppDispatch) => {
|
||||
const accounts: APIEntity[] = [];
|
||||
const normalStatuses: APIEntity[] = [];
|
||||
const polls: APIEntity[] = [];
|
||||
|
@ -162,7 +161,11 @@ const importFetchedStatuses = (statuses: APIEntity[]) =>
|
|||
if (isBroken(status)) return;
|
||||
|
||||
normalStatuses.push(status);
|
||||
|
||||
accounts.push(status.account);
|
||||
if (status.accounts) {
|
||||
accounts.push(...status.accounts);
|
||||
}
|
||||
|
||||
if (status.reblog?.id) {
|
||||
processStatus(status.reblog);
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
Ładowanie…
Reference in New Issue