pl-hooks: Move back to separate directory
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
40
packages/pl-hooks/lib/hooks/accounts/useAccount.ts
Normal file
40
packages/pl-hooks/lib/hooks/accounts/useAccount.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useRelationship } from 'pl-fe/api/hooks/accounts/useRelationship';
|
||||
import { useClient } from 'pl-fe/hooks';
|
||||
import { normalizeAccount } from 'pl-fe/pl-hooks/normalizers/normalizeAccount';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
|
||||
interface UseAccountOpts {
|
||||
withRelationship?: boolean;
|
||||
withScrobble?: boolean;
|
||||
withMoveTarget?: boolean;
|
||||
}
|
||||
|
||||
const useAccount = (accountId?: string, opts: UseAccountOpts = {}) => {
|
||||
const client = useClient();
|
||||
|
||||
const accountQuery = useQuery({
|
||||
queryKey: ['accounts', 'entities', accountId],
|
||||
queryFn: () => client.accounts.getAccount(accountId!)
|
||||
.then(normalizeAccount),
|
||||
enabled: !!accountId,
|
||||
});
|
||||
|
||||
const relationshipQuery = useRelationship(accountId, {
|
||||
enabled: opts.withRelationship,
|
||||
});
|
||||
|
||||
let data;
|
||||
if (accountQuery.data) {
|
||||
data = {
|
||||
...accountQuery.data,
|
||||
relationship: relationshipQuery.relationship,
|
||||
moved: opts.withMoveTarget && queryClient.getQueryData(['accounts', 'entities', accountQuery.data?.moved_id]) as MinifiedAccount || null,
|
||||
};
|
||||
} else data = null;
|
||||
|
||||
return { ...accountQuery, data };
|
||||
};
|
||||
|
||||
export { useAccount };
|
||||
25
packages/pl-hooks/lib/hooks/markers/useMarkers.ts
Normal file
25
packages/pl-hooks/lib/hooks/markers/useMarkers.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useClient } from 'pl-fe/hooks';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
|
||||
import type { PlApiClient } from 'pl-api';
|
||||
|
||||
type Timeline = 'home' | 'notifications';
|
||||
|
||||
const useMarker = (timeline: Timeline) => {
|
||||
const client = useClient();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['markers', timeline],
|
||||
queryFn: () => client.timelines.getMarkers([timeline]).then(markers => markers[timeline]),
|
||||
});
|
||||
};
|
||||
|
||||
const prefetchMarker = (client: PlApiClient, timeline: 'home' | 'notifications') =>
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['markers', timeline],
|
||||
queryFn: () => client.timelines.getMarkers([timeline]).then(markers => markers[timeline]),
|
||||
});
|
||||
|
||||
export { useMarker, prefetchMarker, type Timeline };
|
||||
@ -0,0 +1,26 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useClient } from 'pl-fe/hooks';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
|
||||
import type { Timeline } from './useMarkers';
|
||||
import type { Marker } from 'pl-api';
|
||||
|
||||
const useUpdateMarkerMutation = (timeline: Timeline) => {
|
||||
const client = useClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (lastReadId: string) => client.timelines.saveMarkers({
|
||||
[timeline]: {
|
||||
last_read_id: lastReadId,
|
||||
},
|
||||
}),
|
||||
retry: false,
|
||||
onMutate: (lastReadId) => queryClient.setQueryData<Marker>(['markers', timeline], (marker) => marker ? ({
|
||||
...marker,
|
||||
last_read_id: lastReadId,
|
||||
}) : undefined),
|
||||
});
|
||||
};
|
||||
|
||||
export { useUpdateMarkerMutation };
|
||||
51
packages/pl-hooks/lib/hooks/notifications/useNotification.ts
Normal file
51
packages/pl-hooks/lib/hooks/notifications/useNotification.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
|
||||
import { useAppSelector, useClient } from 'pl-fe/hooks';
|
||||
import { normalizeNotification, type Notification } from 'pl-fe/normalizers';
|
||||
import { type MinifiedNotification, minifyNotification } from 'pl-fe/pl-hooks/minifiers/minifyNotification';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
import { selectAccount, selectAccounts } from 'pl-fe/selectors';
|
||||
|
||||
type Account = ReturnType<typeof selectAccount>;
|
||||
|
||||
const importNotification = (notification: MinifiedNotification) => {
|
||||
queryClient.setQueryData<MinifiedNotification>(
|
||||
['notifications', 'entities', notification.id],
|
||||
existingNotification => existingNotification?.duplicate ? existingNotification : notification,
|
||||
);
|
||||
};
|
||||
|
||||
const useNotification = (notificationId: string) => {
|
||||
const client = useClient();
|
||||
|
||||
const notificationQuery = useQuery({
|
||||
queryKey: ['notifications', 'entities', notificationId],
|
||||
queryFn: () => client.notifications.getNotification(notificationId)
|
||||
.then(normalizeNotification)
|
||||
.then(minifyNotification),
|
||||
});
|
||||
|
||||
const data: Notification | null = useAppSelector((state) => {
|
||||
const notification = notificationQuery.data;
|
||||
if (!notification) return null;
|
||||
const account = selectAccount(state, notification.account_id)!;
|
||||
// @ts-ignore
|
||||
const target = selectAccount(state, notification.target_id)!;
|
||||
// @ts-ignore
|
||||
const status = state.statuses.get(notification.status_id)!;
|
||||
const accounts = selectAccounts(state, notification.account_ids).filter((account): account is Account => account !== undefined);
|
||||
|
||||
return {
|
||||
...notification,
|
||||
account,
|
||||
target,
|
||||
status,
|
||||
accounts,
|
||||
};
|
||||
});
|
||||
|
||||
return { ...notificationQuery, data };
|
||||
};
|
||||
|
||||
export { useNotification, importNotification };
|
||||
@ -0,0 +1,68 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useClient } from 'pl-fe/hooks';
|
||||
import { importEntities } from 'pl-fe/pl-hooks/importer';
|
||||
import { deduplicateNotifications } from 'pl-fe/pl-hooks/normalizers/deduplicateNotifications';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
import { flattenPages } from 'pl-fe/utils/queries';
|
||||
|
||||
import type { Notification as BaseNotification, PaginatedResponse, PlApiClient } from 'pl-api';
|
||||
import type { NotificationType } from 'pl-fe/utils/notification';
|
||||
|
||||
type UseNotificationParams = {
|
||||
types?: Array<NotificationType>;
|
||||
excludeTypes?: Array<NotificationType>;
|
||||
}
|
||||
|
||||
const getQueryKey = (params: UseNotificationParams) => [
|
||||
'notifications',
|
||||
'lists',
|
||||
params.types ? params.types.join('|') : params.excludeTypes ? ('exclude:' + params.excludeTypes.join('|')) : 'all',
|
||||
];
|
||||
|
||||
const importNotifications = (response: PaginatedResponse<BaseNotification>) => {
|
||||
const deduplicatedNotifications = deduplicateNotifications(response.items);
|
||||
|
||||
importEntities({
|
||||
notifications: deduplicatedNotifications,
|
||||
});
|
||||
|
||||
return {
|
||||
items: deduplicatedNotifications.filter(({ duplicate }) => !duplicate).map(({ id }) => id),
|
||||
previous: response.previous,
|
||||
next: response.next,
|
||||
};
|
||||
};
|
||||
|
||||
const useNotificationList = (params: UseNotificationParams) => {
|
||||
const client = useClient();
|
||||
|
||||
const notificationsQuery = useInfiniteQuery({
|
||||
queryKey: getQueryKey(params),
|
||||
queryFn: ({ pageParam }) => (pageParam.next ? pageParam.next() : client.notifications.getNotifications({
|
||||
types: params.types,
|
||||
exclude_types: params.excludeTypes,
|
||||
})).then(importNotifications),
|
||||
initialPageParam: { previous: null, next: null } as Pick<PaginatedResponse<BaseNotification>, 'previous' | 'next'>,
|
||||
getNextPageParam: (response) => response,
|
||||
});
|
||||
|
||||
const data = flattenPages<string>(notificationsQuery.data) || [];
|
||||
|
||||
return {
|
||||
...notificationsQuery,
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const prefetchNotifications = (client: PlApiClient, params: UseNotificationParams) =>
|
||||
queryClient.prefetchInfiniteQuery({
|
||||
queryKey: getQueryKey(params),
|
||||
queryFn: () => client.notifications.getNotifications({
|
||||
types: params.types,
|
||||
exclude_types: params.excludeTypes,
|
||||
}).then(importNotifications),
|
||||
initialPageParam: { previous: null, next: null } as Pick<PaginatedResponse<BaseNotification>, 'previous' | 'next'>,
|
||||
});
|
||||
|
||||
export { useNotificationList, prefetchNotifications };
|
||||
133
packages/pl-hooks/lib/hooks/statuses/useStatus.ts
Normal file
133
packages/pl-hooks/lib/hooks/statuses/useStatus.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { useAccount } from 'pl-fe/api/hooks';
|
||||
import { useAppSelector, useClient } from 'pl-fe/hooks';
|
||||
import { importEntities } from 'pl-fe/pl-hooks/importer';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
import { selectAccount, selectAccounts } from 'pl-fe/selectors';
|
||||
|
||||
import { normalizeStatus, type Status } from '../../normalizers/normalizeStatus';
|
||||
|
||||
// import type { Group } from 'pl-fe/normalizers';
|
||||
|
||||
type Account = ReturnType<typeof selectAccount>;
|
||||
|
||||
// const toServerSideType = (columnType: string): Filter['context'][0] => {
|
||||
// switch (columnType) {
|
||||
// case 'home':
|
||||
// case 'notifications':
|
||||
// case 'public':
|
||||
// case 'thread':
|
||||
// return columnType;
|
||||
// default:
|
||||
// if (columnType.includes('list:')) {
|
||||
// return 'home';
|
||||
// } else {
|
||||
// return 'public'; // community, account, hashtag
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// type FilterContext = { contextType?: string };
|
||||
|
||||
// const getFilters = (state: RootState, query: FilterContext) =>
|
||||
// state.filters.filter((filter) =>
|
||||
// (!query?.contextType || filter.context.includes(toServerSideType(query.contextType)))
|
||||
// && (filter.expires_at === null || Date.parse(filter.expires_at) > new Date().getTime()),
|
||||
// );
|
||||
|
||||
// const escapeRegExp = (string: string) =>
|
||||
// string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
|
||||
// const regexFromFilters = (filters: ImmutableList<Filter>) => {
|
||||
// if (filters.size === 0) return null;
|
||||
|
||||
// return new RegExp(filters.map(filter =>
|
||||
// filter.keywords.map(keyword => {
|
||||
// let expr = escapeRegExp(keyword.keyword);
|
||||
|
||||
// if (keyword.whole_word) {
|
||||
// if (/^[\w]/.test(expr)) {
|
||||
// expr = `\\b${expr}`;
|
||||
// }
|
||||
|
||||
// if (/[\w]$/.test(expr)) {
|
||||
// expr = `${expr}\\b`;
|
||||
// }
|
||||
// }
|
||||
|
||||
// return expr;
|
||||
// }).join('|'),
|
||||
// ).join('|'), 'i');
|
||||
// };
|
||||
|
||||
// const checkFiltered = (index: string, filters: ImmutableList<Filter>) =>
|
||||
// filters.reduce((result: Array<string>, filter) =>
|
||||
// result.concat(filter.keywords.reduce((result: Array<string>, keyword) => {
|
||||
// let expr = escapeRegExp(keyword.keyword);
|
||||
|
||||
// if (keyword.whole_word) {
|
||||
// if (/^[\w]/.test(expr)) {
|
||||
// expr = `\\b${expr}`;
|
||||
// }
|
||||
|
||||
// if (/[\w]$/.test(expr)) {
|
||||
// expr = `${expr}\\b`;
|
||||
// }
|
||||
// }
|
||||
|
||||
// const regex = new RegExp(expr);
|
||||
|
||||
// if (regex.test(index)) return result.concat(filter.title);
|
||||
// return result;
|
||||
// }, [])), []);
|
||||
|
||||
const importStatus = (status: Status) => {
|
||||
queryClient.setQueryData<Status>(
|
||||
['statuses', 'entities', status.id],
|
||||
status,
|
||||
);
|
||||
};
|
||||
|
||||
const useStatus = (statusId?: string) => {
|
||||
const client = useClient();
|
||||
const intl = useIntl();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['statuses', 'entities', statusId],
|
||||
queryFn: () => client.statuses.getStatus(statusId!, {
|
||||
language: intl.locale,
|
||||
})
|
||||
.then(status => (importEntities({ statuses: [status] }, { withParents: false }), status))
|
||||
.then(normalizeStatus),
|
||||
enabled: !!statusId,
|
||||
});
|
||||
|
||||
const status = statusQuery.data;
|
||||
|
||||
const { account } = useAccount(status?.account_id || undefined);
|
||||
|
||||
// : (Status & {
|
||||
// account: Account;
|
||||
// accounts: Array<Account>;
|
||||
// reblog: Status | null;
|
||||
// }) | null
|
||||
const data = useAppSelector((state) => {
|
||||
if (!status) return null;
|
||||
const accounts = selectAccounts(state, status.account_ids).filter((account): account is Account => account !== undefined);
|
||||
|
||||
return {
|
||||
...status,
|
||||
account: account!,
|
||||
accounts,
|
||||
// quote,
|
||||
// reblog,
|
||||
// poll
|
||||
};
|
||||
});
|
||||
|
||||
return { ...statusQuery, data };
|
||||
};
|
||||
|
||||
export { useStatus, importStatus };
|
||||
108
packages/pl-hooks/lib/importer.ts
Normal file
108
packages/pl-hooks/lib/importer.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { importAccounts, importGroups, importPolls, importStatuses } from 'pl-fe/actions/importer';
|
||||
import { importEntities as importEntityStoreEntities } from 'pl-fe/entity-store/actions';
|
||||
import { Entities } from 'pl-fe/entity-store/entities';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
|
||||
import { minifyNotification, type MinifiedNotification } from './minifiers/minifyNotification';
|
||||
import { DeduplicatedNotification } from './normalizers/deduplicateNotifications';
|
||||
import { normalizeStatus, type Status } from './normalizers/normalizeStatus';
|
||||
|
||||
import type {
|
||||
Account as BaseAccount,
|
||||
Group as BaseGroup,
|
||||
Poll as BasePoll,
|
||||
Relationship as BaseRelationship,
|
||||
Status as BaseStatus,
|
||||
} from 'pl-api';
|
||||
import type { AppDispatch } from 'pl-fe/store';
|
||||
|
||||
let dispatch: AppDispatch;
|
||||
|
||||
import('pl-fe/store').then(value => dispatch = value.store.dispatch).catch(() => {});
|
||||
|
||||
const importNotification = (notification: DeduplicatedNotification) => {
|
||||
queryClient.setQueryData<MinifiedNotification>(
|
||||
['notifications', 'entities', notification.id],
|
||||
existingNotification => existingNotification?.duplicate ? existingNotification : minifyNotification(notification),
|
||||
);
|
||||
};
|
||||
|
||||
const importStatus = (status: BaseStatus) => {
|
||||
queryClient.setQueryData<Status>(
|
||||
['statuses', 'entities', status.id],
|
||||
_ => normalizeStatus(status),
|
||||
);
|
||||
};
|
||||
|
||||
const isEmpty = (object: Record<string, any>) => {
|
||||
for (const i in object) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const importEntities = (entities: {
|
||||
accounts?: Array<BaseAccount>;
|
||||
groups?: Array<BaseGroup>;
|
||||
notifications?: Array<DeduplicatedNotification>;
|
||||
polls?: Array<BasePoll>;
|
||||
statuses?: Array<BaseStatus>;
|
||||
relationships?: Array<BaseRelationship>;
|
||||
}, options = {
|
||||
withParents: true,
|
||||
}) => {
|
||||
const accounts: Record<string, BaseAccount> = {};
|
||||
const groups: Record<string, BaseGroup> = {};
|
||||
const notifications: Record<string, DeduplicatedNotification> = {};
|
||||
const polls: Record<string, BasePoll> = {};
|
||||
const relationships: Record<string, BaseRelationship> = {};
|
||||
const statuses: Record<string, BaseStatus> = {};
|
||||
|
||||
const processAccount = (account: BaseAccount, withParent = true) => {
|
||||
if (withParent) accounts[account.id] = account;
|
||||
|
||||
if (account.moved) processAccount(account.moved);
|
||||
if (account.relationship) relationships[account.relationship.id] = account.relationship;
|
||||
};
|
||||
|
||||
const processNotification = (notification: DeduplicatedNotification, withParent = true) => {
|
||||
if (withParent) notifications[notification.id] = notification;
|
||||
|
||||
processAccount(notification.account);
|
||||
if (notification.type === 'move') processAccount(notification.target);
|
||||
|
||||
if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(notification.type))
|
||||
// @ts-ignore
|
||||
processStatus(notification.status);
|
||||
};
|
||||
|
||||
const processStatus = (status: BaseStatus, withParent = true) => {
|
||||
if (status.account) {
|
||||
if (withParent) statuses[status.id] = status;
|
||||
processAccount(status.account);
|
||||
}
|
||||
|
||||
if (status.quote) processStatus(status.quote);
|
||||
if (status.reblog) processStatus(status.reblog);
|
||||
if (status.poll) polls[status.poll.id] = status.poll;
|
||||
if (status.group) groups[status.group.id] = status.group;
|
||||
};
|
||||
|
||||
if (options.withParents) {
|
||||
entities.groups?.forEach(group => groups[group.id] = group);
|
||||
entities.polls?.forEach(poll => polls[poll.id] = poll);
|
||||
entities.relationships?.forEach(relationship => relationships[relationship.id] = relationship);
|
||||
}
|
||||
|
||||
entities.accounts?.forEach((account) => processAccount(account, options.withParents));
|
||||
entities.notifications?.forEach((notification) => processNotification(notification, options.withParents));
|
||||
entities.statuses?.forEach((status) => processStatus(status, options.withParents));
|
||||
|
||||
if (!isEmpty(accounts)) dispatch(importAccounts(Object.values(accounts)));
|
||||
if (!isEmpty(groups)) dispatch(importGroups(Object.values(groups)));
|
||||
if (!isEmpty(notifications)) Object.values(notifications).forEach(importNotification);
|
||||
if (!isEmpty(polls)) dispatch(importPolls(Object.values(polls)));
|
||||
if (!isEmpty(relationships)) dispatch(importEntityStoreEntities(Object.values(relationships), Entities.RELATIONSHIPS));
|
||||
if (!isEmpty(statuses)) dispatch(importStatuses(Object.values(statuses)));
|
||||
if (!isEmpty(statuses)) Object.values(statuses).forEach(importStatus);
|
||||
};
|
||||
|
||||
export { importEntities };
|
||||
0
packages/pl-hooks/lib/main.ts
Normal file
0
packages/pl-hooks/lib/main.ts
Normal file
79
packages/pl-hooks/lib/minifiers/minifyNotification.ts
Normal file
79
packages/pl-hooks/lib/minifiers/minifyNotification.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
import { DeduplicatedNotification } from '../normalizers/deduplicateNotifications';
|
||||
|
||||
import type { AccountWarning, RelationshipSeveranceEvent } from 'pl-api';
|
||||
|
||||
const minifyNotification = (notification: DeduplicatedNotification) => {
|
||||
// @ts-ignore
|
||||
const minifiedNotification: {
|
||||
duplicate: boolean;
|
||||
account_id: string;
|
||||
account_ids: string[];
|
||||
created_at: string;
|
||||
id: string;
|
||||
group_key: string;
|
||||
} & (
|
||||
| { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' }
|
||||
| {
|
||||
type: 'mention';
|
||||
subtype?: 'reply';
|
||||
status_id: string;
|
||||
}
|
||||
| {
|
||||
type: 'status' | 'reblog' | 'favourite' | 'poll' | 'update' | 'event_reminder';
|
||||
status_id: string;
|
||||
}
|
||||
| {
|
||||
type: 'admin.report';
|
||||
report: Report;
|
||||
}
|
||||
| {
|
||||
type: 'severed_relationships';
|
||||
relationship_severance_event: RelationshipSeveranceEvent;
|
||||
}
|
||||
| {
|
||||
type: 'moderation_warning';
|
||||
moderation_warning: AccountWarning;
|
||||
}
|
||||
| {
|
||||
type: 'move';
|
||||
target_id: string;
|
||||
}
|
||||
| {
|
||||
type: 'emoji_reaction';
|
||||
emoji: string;
|
||||
emoji_url: string | null;
|
||||
status_id: string;
|
||||
}
|
||||
| {
|
||||
type: 'chat_mention';
|
||||
chat_message_id: string;
|
||||
}
|
||||
| {
|
||||
type: 'participation_accepted' | 'participation_request';
|
||||
status_id: string;
|
||||
participation_message: string | null;
|
||||
}
|
||||
) = {
|
||||
...omit(notification, ['account', 'accounts', 'status', 'target', 'chat_message']),
|
||||
account_id: notification.account.id,
|
||||
account_ids: notification.accounts.map(({ id }) => id),
|
||||
created_at: notification.created_at,
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
if (notification.status) minifiedNotification.status_id = notification.status.id;
|
||||
// @ts-ignore
|
||||
if (notification.target) minifiedNotification.target_id = notification.target.id;
|
||||
// @ts-ignore
|
||||
if (notification.chat_message) minifiedNotification.chat_message_id = notification.chat_message.id;
|
||||
|
||||
return minifiedNotification;
|
||||
};
|
||||
|
||||
type MinifiedNotification = ReturnType<typeof minifyNotification>;
|
||||
|
||||
export { minifyNotification, type MinifiedNotification };
|
||||
@ -0,0 +1,45 @@
|
||||
import { getNotificationStatus } from 'pl-fe/features/notifications/components/notification';
|
||||
|
||||
import type { Account as BaseAccount, Notification as BaseNotification } from 'pl-api';
|
||||
|
||||
type DeduplicatedNotification = BaseNotification & {
|
||||
accounts: Array<BaseAccount>;
|
||||
duplicate?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_NOTIFICATION_TYPES = [
|
||||
'favourite',
|
||||
'reblog',
|
||||
'emoji_reaction',
|
||||
'event_reminder',
|
||||
'participation_accepted',
|
||||
'participation_request',
|
||||
];
|
||||
|
||||
const deduplicateNotifications = (notifications: Array<BaseNotification>) => {
|
||||
const deduplicatedNotifications: DeduplicatedNotification[] = [];
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (STATUS_NOTIFICATION_TYPES.includes(notification.type)) {
|
||||
const existingNotification = deduplicatedNotifications
|
||||
.find(deduplicated =>
|
||||
deduplicated.type === notification.type
|
||||
&& ((notification.type === 'emoji_reaction' && deduplicated.type === 'emoji_reaction') ? notification.emoji === deduplicated.emoji : true)
|
||||
&& getNotificationStatus(deduplicated)?.id === getNotificationStatus(notification)?.id,
|
||||
);
|
||||
|
||||
if (existingNotification) {
|
||||
existingNotification.accounts.push(notification.account);
|
||||
deduplicatedNotifications.push({ ...notification, accounts: [notification.account], duplicate: true });
|
||||
} else {
|
||||
deduplicatedNotifications.push({ ...notification, accounts: [notification.account], duplicate: false });
|
||||
}
|
||||
} else {
|
||||
deduplicatedNotifications.push({ ...notification, accounts: [notification.account], duplicate: false });
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicatedNotifications;
|
||||
};
|
||||
|
||||
export { deduplicateNotifications, type DeduplicatedNotification };
|
||||
38
packages/pl-hooks/lib/normalizers/normalizeAccount.ts
Normal file
38
packages/pl-hooks/lib/normalizers/normalizeAccount.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import emojify from 'pl-fe/features/emoji';
|
||||
import { unescapeHTML } from 'pl-fe/utils/html';
|
||||
import { makeEmojiMap } from 'pl-fe/utils/normalizers';
|
||||
|
||||
import type { Account as BaseAccount } from 'pl-api';
|
||||
|
||||
const normalizeAccount = ({ moved, ...account }: BaseAccount) => {
|
||||
const missingAvatar = require('pl-fe/assets/images/avatar-missing.png');
|
||||
const missingHeader = require('pl-fe/assets/images/header-missing.png');
|
||||
const note = account.note === '<p></p>' ? '' : account.note;
|
||||
|
||||
const emojiMap = makeEmojiMap(account.emojis);
|
||||
|
||||
return {
|
||||
...account,
|
||||
moved_id: moved?.id || null,
|
||||
avatar: account.avatar || account.avatar_static || missingAvatar,
|
||||
avatar_static: account.avatar_static || account.avatar || missingAvatar,
|
||||
header: account.header || account.header_static || missingHeader,
|
||||
header_static: account.header_static || account.header || missingHeader,
|
||||
note,
|
||||
display_name_html: emojify(escapeTextContentForBrowser(account.display_name), emojiMap),
|
||||
note_emojified: emojify(account.note, emojiMap),
|
||||
note_plain: unescapeHTML(account.note),
|
||||
fields: account.fields.map(field => ({
|
||||
...field,
|
||||
name_emojified: emojify(escapeTextContentForBrowser(field.name), emojiMap),
|
||||
value_emojified: emojify(field.value, emojiMap),
|
||||
value_plain: unescapeHTML(field.value),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type Account = ReturnType<typeof normalizeAccount>;
|
||||
|
||||
export { normalizeAccount, type Account };
|
||||
171
packages/pl-hooks/lib/normalizers/normalizeStatus.ts
Normal file
171
packages/pl-hooks/lib/normalizers/normalizeStatus.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Status normalizer:
|
||||
* Converts API statuses into our internal format.
|
||||
* @see {@link https://docs.joinmastodon.org/entities/status/}
|
||||
*/
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { type Account as BaseAccount, type Status as BaseStatus, type MediaAttachment, mentionSchema, type Translation } from 'pl-api';
|
||||
|
||||
import emojify from 'pl-fe/features/emoji';
|
||||
import { queryClient } from 'pl-fe/queries/client';
|
||||
import { unescapeHTML } from 'pl-fe/utils/html';
|
||||
import { makeEmojiMap } from 'pl-fe/utils/normalizers';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
type StatusApprovalStatus = Exclude<BaseStatus['approval_status'], null>;
|
||||
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'group' | 'mutuals_only' | 'local';
|
||||
|
||||
type CalculatedValues = {
|
||||
search_index: string;
|
||||
contentHtml: string;
|
||||
spoilerHtml: string;
|
||||
contentMapHtml?: Record<string, string>;
|
||||
spoilerMapHtml?: Record<string, string>;
|
||||
expanded?: boolean | null;
|
||||
hidden?: boolean | null;
|
||||
translation?: Translation | null | false;
|
||||
currentLanguage?: string;
|
||||
};
|
||||
|
||||
type OldStatus = Pick<BaseStatus, 'content' | 'spoiler_text'> & CalculatedValues;
|
||||
|
||||
// Gets titles of poll options from status
|
||||
const getPollOptionTitles = ({ poll }: Pick<BaseStatus, 'poll'>): readonly string[] => {
|
||||
if (poll && typeof poll === 'object') {
|
||||
return poll.options.map(({ title }) => title);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Gets usernames of mentioned users from status
|
||||
const getMentionedUsernames = (status: Pick<BaseStatus, 'mentions'>): Array<string> =>
|
||||
status.mentions.map(({ acct }) => `@${acct}`);
|
||||
|
||||
// Creates search text from the status
|
||||
const buildSearchContent = (status: Pick<BaseStatus, 'poll' | 'mentions' | 'spoiler_text' | 'content'>): string => {
|
||||
const pollOptionTitles = getPollOptionTitles(status);
|
||||
const mentionedUsernames = getMentionedUsernames(status);
|
||||
|
||||
const fields = [
|
||||
status.spoiler_text,
|
||||
status.content,
|
||||
...pollOptionTitles,
|
||||
...mentionedUsernames,
|
||||
];
|
||||
|
||||
return unescapeHTML(fields.join('\n\n')) || '';
|
||||
};
|
||||
|
||||
const calculateContent = (text: string, emojiMap: any, hasQuote?: boolean) => emojify(text, emojiMap);
|
||||
const calculateSpoiler = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { USE_PROFILES: { html: true } });
|
||||
|
||||
const calculateStatus = (status: BaseStatus, oldStatus?: OldStatus): CalculatedValues => {
|
||||
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
|
||||
const {
|
||||
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, expanded, translation, currentLanguage,
|
||||
} = oldStatus;
|
||||
|
||||
return {
|
||||
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, expanded, translation, currentLanguage,
|
||||
};
|
||||
} else {
|
||||
const searchContent = buildSearchContent(status);
|
||||
const emojiMap = makeEmojiMap(status.emojis);
|
||||
|
||||
return {
|
||||
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
|
||||
contentHtml: calculateContent(status.content, emojiMap, !!status.quote),
|
||||
spoilerHtml: calculateSpoiler(status.spoiler_text, emojiMap),
|
||||
contentMapHtml: status.content_map
|
||||
? Object.fromEntries(Object.entries(status.content_map)?.map(([key, value]) => [key, calculateContent(value, emojiMap, !!status.quote)]))
|
||||
: undefined,
|
||||
spoilerMapHtml: status.spoiler_text_map
|
||||
? Object.fromEntries(Object.entries(status.spoiler_text_map).map(([key, value]) => [key, calculateSpoiler(value, emojiMap)]))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeStatus = ({ account, accounts, reblog, poll, group, quote, ...status }: BaseStatus & { accounts?: Array<BaseAccount> }) => {
|
||||
const oldStatus = queryClient.getQueryData<OldStatus>(['statuses', 'entities', status.id]);
|
||||
const calculated = calculateStatus(status, oldStatus);
|
||||
|
||||
// Sort the replied-to mention to the top
|
||||
let mentions = status.mentions.toSorted((a, _b) => {
|
||||
if (a.id === status.in_reply_to_account_id) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Add self to mentions if it's a reply to self
|
||||
const isSelfReply = account.id === status.in_reply_to_account_id;
|
||||
const hasSelfMention = status.mentions.some(mention => account.id === mention.id);
|
||||
|
||||
if (isSelfReply && !hasSelfMention) {
|
||||
const selfMention = mentionSchema.parse(status.account);
|
||||
mentions = [selfMention, ...mentions];
|
||||
}
|
||||
|
||||
// Normalize event
|
||||
let event: BaseStatus['event'] & ({
|
||||
banner: MediaAttachment | null;
|
||||
links: Array<MediaAttachment>;
|
||||
} | null) = null;
|
||||
let media_attachments = status.media_attachments;
|
||||
|
||||
if (status.event) {
|
||||
const firstAttachment = status.media_attachments[0];
|
||||
let banner: MediaAttachment | null = null;
|
||||
|
||||
if (firstAttachment?.description === 'Banner' && firstAttachment.type === 'image') {
|
||||
banner = firstAttachment;
|
||||
media_attachments = media_attachments.slice(1);
|
||||
}
|
||||
|
||||
const links = media_attachments.filter(attachment => attachment.mime_type === 'text/html');
|
||||
media_attachments = media_attachments.filter(attachment => attachment.mime_type !== 'text/html');
|
||||
|
||||
event = {
|
||||
...status.event,
|
||||
banner,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
account_ids: accounts?.map(account => account.id) || [account.id],
|
||||
reblog_id: reblog?.id || null,
|
||||
poll_id: poll?.id || null,
|
||||
group_id: group?.id || null,
|
||||
translating: false,
|
||||
expectsCard: false,
|
||||
showFiltered: null as null | boolean,
|
||||
...status,
|
||||
quote_id: quote?.id || status.quote_id || null,
|
||||
mentions,
|
||||
expanded: null,
|
||||
hidden: null,
|
||||
/** Rewrite `<p></p>` to empty string. */
|
||||
content: status.content === '<p></p>' ? '' : status.content,
|
||||
filtered: status.filtered?.map(result => result.filter.title),
|
||||
event,
|
||||
media_attachments,
|
||||
...calculated,
|
||||
translation: (status.translation || calculated.translation || null) as Translation | null | false,
|
||||
};
|
||||
};
|
||||
|
||||
type Status = ReturnType<typeof normalizeStatus>;
|
||||
|
||||
export {
|
||||
type StatusApprovalStatus,
|
||||
type StatusVisibility,
|
||||
normalizeStatus,
|
||||
type Status,
|
||||
};
|
||||
Reference in New Issue
Block a user