Merge pull request #141 from mkljczk/pl-api-updates

This commit is contained in:
marcin mikołajczak 2024-11-03 20:31:31 +01:00 committed by GitHub
commit d0a036c63c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 642 additions and 235 deletions

View file

@ -1,3 +1,5 @@
import omit from 'lodash.omit';
import pick from 'lodash.pick';
import * as v from 'valibot'; import * as v from 'valibot';
import { import {
@ -27,6 +29,7 @@ import {
contextSchema, contextSchema,
conversationSchema, conversationSchema,
credentialAccountSchema, credentialAccountSchema,
credentialApplicationSchema,
customEmojiSchema, customEmojiSchema,
domainBlockSchema, domainBlockSchema,
emojiReactionSchema, emojiReactionSchema,
@ -70,6 +73,7 @@ import {
trendsLinkSchema, trendsLinkSchema,
webPushSubscriptionSchema, webPushSubscriptionSchema,
} from './entities'; } from './entities';
import { GroupedNotificationsResults, groupedNotificationsResultsSchema, NotificationGroup } from './entities/grouped-notifications-results';
import { filteredArray } from './entities/utils'; import { filteredArray } from './entities/utils';
import { AKKOMA, type Features, getFeatures, GOTOSOCIAL, MITRA } from './features'; import { AKKOMA, type Features, getFeatures, GOTOSOCIAL, MITRA } from './features';
import { import {
@ -107,6 +111,7 @@ import {
MuteAccountParams, MuteAccountParams,
UpdateFilterParams, UpdateFilterParams,
} from './params/filtering'; } from './params/filtering';
import { GetGroupedNotificationsParams, GetUnreadNotificationGroupCountParams } from './params/grouped-notifications';
import { import {
CreateGroupParams, CreateGroupParams,
GetGroupBlocksParams, GetGroupBlocksParams,
@ -189,12 +194,14 @@ import request, { getNextLink, getPrevLink, type RequestBody, RequestMeta } from
import { buildFullPath } from './utils/url'; import { buildFullPath } from './utils/url';
import type { import type {
Account,
AdminAccount, AdminAccount,
AdminAnnouncement, AdminAnnouncement,
AdminModerationLogEntry, AdminModerationLogEntry,
AdminReport, AdminReport,
GroupRole, GroupRole,
Instance, Instance,
Notification,
PleromaConfig, PleromaConfig,
Status, Status,
StreamingEvent, StreamingEvent,
@ -228,7 +235,9 @@ import type {
AdminUpdateRuleParams, AdminUpdateRuleParams,
AdminUpdateStatusParams, AdminUpdateStatusParams,
} from './params/admin'; } from './params/admin';
import type { PaginatedResponse } from './responses'; import type { PaginatedResponse, PaginatedSingleResponse } from './responses';
const GROUPED_TYPES = ['favourite', 'reblog', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'];
/** /**
* @category Clients * @category Clients
@ -292,6 +301,28 @@ class PlApiClient {
}; };
}; };
#paginatedSingleGet = async <T>(input: URL | RequestInfo, body: RequestBody, schema: v.BaseSchema<any, T, v.BaseIssue<unknown>>): Promise<PaginatedSingleResponse<T>> => {
const getMore = (input: string | null) => input ? async () => {
const response = await this.request(input);
return {
previous: getMore(getPrevLink(response)),
next: getMore(getNextLink(response)),
items: v.parse(schema, response.json),
partial: response.status === 206,
};
} : null;
const response = await this.request(input, body);
return {
previous: getMore(getPrevLink(response)),
next: getMore(getNextLink(response)),
items: v.parse(schema, response.json),
partial: response.status === 206,
};
};
#paginatedPleromaAccounts = async (params: { #paginatedPleromaAccounts = async (params: {
query?: string; query?: string;
filters?: string; filters?: string;
@ -353,6 +384,65 @@ class PlApiClient {
}; };
}; };
#groupNotifications = ({ previous, next, items, ...response }: PaginatedResponse<Notification>, params?: GetGroupedNotificationsParams): PaginatedSingleResponse<GroupedNotificationsResults> => {
const notificationGroups: Array<NotificationGroup> = [];
for (const notification of items) {
let existingGroup: NotificationGroup | undefined;
if ((params?.grouped_types || GROUPED_TYPES).includes(notification.type)) {
existingGroup = notificationGroups
.find(notificationGroup =>
notificationGroup.type === notification.type
&& ((notification.type === 'emoji_reaction' && notificationGroup.type === 'emoji_reaction') ? notification.emoji === notificationGroup.emoji : true)
// @ts-ignore
&& notificationGroup.status_id === notification.status?.id,
);
}
if (existingGroup) {
existingGroup.notifications_count += 1;
existingGroup.page_min_id = notification.id;
existingGroup.sample_account_ids.push(notification.account.id);
} else {
notificationGroups.push({
...(omit(notification, ['account', 'status', 'target'])),
group_key: notification.id,
notifications_count: 1,
most_recent_notification_id: notification.id,
page_min_id: notification.id,
page_max_id: notification.id,
latest_page_notification_at: notification.created_at,
sample_account_ids: [notification.account.id],
// @ts-ignore
status_id: notification.status?.id,
// @ts-ignore
target_id: notification.target?.id,
});
}
}
const groupedNotificationsResults: GroupedNotificationsResults = {
accounts: Object.values(items.reduce<Record<string, Account>>((accounts, notification) => {
accounts[notification.account.id] = notification.account;
if ('target' in notification) accounts[notification.target.id] = notification.target;
return accounts;
}, {})),
statuses: Object.values(items.reduce<Record<string, Status>>((statuses, notification) => {
if ('status' in notification) statuses[notification.status.id] = notification.status;
return statuses;
}, {})),
notification_groups: notificationGroups,
};
return {
...response,
previous: previous ? async () => this.#groupNotifications(await previous(), params) : null,
next: next ? async () => this.#groupNotifications(await next(), params) : null,
items: groupedNotificationsResults,
};
};
/** Register client applications that can be used to obtain OAuth tokens. */ /** Register client applications that can be used to obtain OAuth tokens. */
public readonly apps = { public readonly apps = {
/** /**
@ -363,7 +453,7 @@ class PlApiClient {
createApplication: async (params: CreateApplicationParams) => { createApplication: async (params: CreateApplicationParams) => {
const response = await this.request('/api/v1/apps', { method: 'POST', body: params }); const response = await this.request('/api/v1/apps', { method: 'POST', body: params });
return v.parse(applicationSchema, response.json); return v.parse(credentialApplicationSchema, response.json);
}, },
/** /**
@ -2705,6 +2795,108 @@ class PlApiClient {
}; };
/**
* It is recommended to only use this with features{@link Features['groupedNotifications']} available. However, there is a fallback that groups the notifications client-side.
*/
public readonly groupedNotifications = {
/**
* Get all grouped notifications
* Return grouped notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.
*
* Requires features{@link Features['groupedNotifications']}.
* @see {@link https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped}
*/
getGroupedNotifications: async (params: GetGroupedNotificationsParams, meta?: RequestMeta) => {
if (this.features.groupedNotifications) {
return this.#paginatedSingleGet('/api/v2/notifications', { ...meta, params }, groupedNotificationsResultsSchema);
} else {
const response = await this.notifications.getNotifications(
pick(params, ['max_id', 'since_id', 'limit', 'min_id', 'types', 'exclude_types', 'account_id', 'include_filtered']),
);
return this.#groupNotifications(response, params);
}
},
/**
* Get a single notification group
* View information about a specific notification group with a given group key.
*
* Requires features{@link Features['groupedNotifications']}.
* @see {@link https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group}
*/
getNotificationGroup: async (groupKey: string) => {
if (this.features.groupedNotifications) {
const response = await this.request(`/api/v2/notifications/${groupKey}`);
return v.parse(groupedNotificationsResultsSchema, response.json);
} else {
const response = await this.request(`/api/v1/notifications/${groupKey}`);
return this.#groupNotifications({
previous: null,
next: null,
items: [response.json],
partial: false,
}).items;
}
},
/**
* Dismiss a single notification group
* Dismiss a single notification group from the server.
*
* Requires features{@link Features['groupedNotifications']}.
* @see {@link https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group}
*/
dismissNotificationGroup: async (groupKey: string) => {
if (this.features.groupedNotifications) {
const response = await this.request(`/api/v2/notifications/${groupKey}/dismiss`, { method: 'POST' });
return response.json as {};
} else {
return this.notifications.dismissNotification(groupKey);
}
},
/**
* Get accounts of all notifications in a notification group
*
* Requires features{@link Features['groupedNotifications']}.
* @see {@link https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts}
*/
getNotificationGroupAccounts: async (groupKey: string) => {
if (this.features.groupedNotifications) {
const response = await this.request(`/api/v2/notifications/${groupKey}/accounts`);
return v.parse(filteredArray(accountSchema), response.json);
} else {
return (await (this.groupedNotifications.getNotificationGroup(groupKey))).accounts;
}
},
/**
* Get the number of unread notifications
* Get the (capped) number of unread notification groups for the current user. A notification is considered unread if it is more recent than the notifications read marker. Because the count is dependant on the parameters, it is computed every time and is thus a relatively slow operation (although faster than getting the full corresponding notifications), therefore the number of returned notifications is capped.
*
* Requires features{@link Features['groupedNotifications']}.
* @see {@link https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count}
*/
getUnreadNotificationGroupCount: async (params: GetUnreadNotificationGroupCountParams) => {
if (this.features.groupedNotifications) {
const response = await this.request('/api/v2/notifications/unread_count', { params });
return v.parse(v.object({
count: v.number(),
}), response.json);
} else {
return this.notifications.getUnreadNotificationCount(
pick(params || {}, ['max_id', 'since_id', 'limit', 'min_id', 'types', 'exclude_types', 'account_id']),
);
}
},
};
public readonly pushNotifications = { public readonly pushNotifications = {
/** /**
* Subscribe to push notifications * Subscribe to push notifications

View file

@ -12,9 +12,6 @@ const applicationSchema = v.pipe(v.any(), v.transform((application) => ({
})), v.object({ })), v.object({
name: v.fallback(v.string(), ''), name: v.fallback(v.string(), ''),
website: v.fallback(v.optional(v.string()), undefined), website: v.fallback(v.optional(v.string()), undefined),
client_id: v.fallback(v.optional(v.string()), undefined),
client_secret: v.fallback(v.optional(v.string()), undefined),
client_secret_expires_at: v.fallback(v.optional(v.string()), undefined),
redirect_uris: filteredArray(v.string()), redirect_uris: filteredArray(v.string()),
id: v.fallback(v.optional(v.string()), undefined), id: v.fallback(v.optional(v.string()), undefined),
@ -27,4 +24,21 @@ const applicationSchema = v.pipe(v.any(), v.transform((application) => ({
type Application = v.InferOutput<typeof applicationSchema>; type Application = v.InferOutput<typeof applicationSchema>;
export { applicationSchema, type Application }; /**
* @category Schemas
* @see {@link https://docs.joinmastodon.org/entities/Application/#CredentialApplication}
*/
const credentialApplicationSchema = v.pipe(
applicationSchema.pipe[0],
applicationSchema.pipe[1],
v.object({
...applicationSchema.pipe[2].entries,
client_id: v.string(),
client_secret: v.string(),
client_secret_expires_at: v.fallback(v.optional(v.string()), undefined),
}),
);
type CredentialApplication = v.InferOutput<typeof credentialApplicationSchema>;
export { applicationSchema, credentialApplicationSchema, type Application, type CredentialApplication };

View file

@ -0,0 +1,151 @@
import pick from 'lodash.pick';
import * as v from 'valibot';
import { accountSchema } from './account';
import { accountWarningSchema } from './account-warning';
import { chatMessageSchema } from './chat-message';
import { relationshipSeveranceEventSchema } from './relationship-severance-event';
import { reportSchema } from './report';
import { statusSchema } from './status';
import { datetimeSchema, filteredArray } from './utils';
const partialAccountWithAvatarSchema = v.object({
id: v.string(),
acct: v.string(),
url: v.pipe(v.string(), v.url()),
avatar: v.pipe(v.string(), v.url()),
avatar_static: v.pipe(v.string(), v.url()),
locked: v.boolean(),
bot: v.boolean(),
});
const baseNotificationGroupSchema = v.object({
group_key: v.string(),
notifications_count: v.pipe(v.number(), v.integer()),
most_recent_notification_id: v.string(),
page_min_id: v.fallback(v.optional(v.string()), undefined),
page_max_id: v.fallback(v.optional(v.string()), undefined),
latest_page_notification_at: v.fallback(v.optional(datetimeSchema), undefined),
sample_account_ids: v.array(v.string()),
is_muted: v.fallback(v.optional(v.boolean()), undefined),
is_seen: v.fallback(v.optional(v.boolean()), undefined),
});
const accountNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.picklist(['follow', 'follow_request', 'admin.sign_up', 'bite']),
});
const mentionNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('mention'),
subtype: v.fallback(v.nullable(v.picklist(['reply'])), null),
status_id: v.string(),
});
const statusNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.picklist(['status', 'reblog', 'favourite', 'poll', 'update', 'event_reminder']),
status_id: v.string(),
});
const reportNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('admin.report'),
report: reportSchema,
});
const severedRelationshipNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('severed_relationships'),
relationship_severance_event: relationshipSeveranceEventSchema,
});
const moderationWarningNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('moderation_warning'),
moderation_warning: accountWarningSchema,
});
const moveNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('move'),
target_id: v.string(),
});
const emojiReactionNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('emoji_reaction'),
emoji: v.string(),
emoji_url: v.fallback(v.nullable(v.string()), null),
status_id: v.string(),
});
const chatMentionNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.literal('chat_mention'),
chat_message: chatMessageSchema,
});
const eventParticipationRequestNotificationGroupSchema = v.object({
...baseNotificationGroupSchema.entries,
type: v.picklist(['participation_accepted', 'participation_request']),
status_id: v.string(),
participation_message: v.fallback(v.nullable(v.string()), null),
});
/**
* @category Schemas
* @see {@link https://docs.joinmastodon.org/entities/Notification/}
* */
const notificationGroupSchema: v.BaseSchema<any, NotificationGroup, v.BaseIssue<unknown>> = v.pipe(
v.any(),
v.transform((notification: any) => ({
group_key: `ungrouped-${notification.id}`,
...pick(notification.pleroma || {}, ['is_muted', 'is_seen']),
...notification,
type: notification.type === 'pleroma:report'
? 'admin.report'
: notification.type?.replace(/^pleroma:/, ''),
})),
v.variant('type', [
accountNotificationGroupSchema,
mentionNotificationGroupSchema,
statusNotificationGroupSchema,
reportNotificationGroupSchema,
severedRelationshipNotificationGroupSchema,
moderationWarningNotificationGroupSchema,
moveNotificationGroupSchema,
emojiReactionNotificationGroupSchema,
chatMentionNotificationGroupSchema,
eventParticipationRequestNotificationGroupSchema,
])) as any;
type NotificationGroup = v.InferOutput<
| typeof accountNotificationGroupSchema
| typeof mentionNotificationGroupSchema
| typeof statusNotificationGroupSchema
| typeof reportNotificationGroupSchema
| typeof severedRelationshipNotificationGroupSchema
| typeof moderationWarningNotificationGroupSchema
| typeof moveNotificationGroupSchema
| typeof emojiReactionNotificationGroupSchema
| typeof chatMentionNotificationGroupSchema
| typeof eventParticipationRequestNotificationGroupSchema
>;
/**
* @category Schemas
* @see {@link https://docs.joinmastodon.org/methods/grouped_notifications/#GroupedNotificationsResults}
*/
const groupedNotificationsResultsSchema = v.object({
accounts: filteredArray(accountSchema),
partial_accounts: v.fallback(v.optional(v.array(partialAccountWithAvatarSchema)), undefined),
statuses: filteredArray(statusSchema),
notification_groups: filteredArray(notificationGroupSchema),
});
type GroupedNotificationsResults = v.InferOutput<typeof groupedNotificationsResultsSchema>;
export { notificationGroupSchema, groupedNotificationsResultsSchema, type NotificationGroup, type GroupedNotificationsResults };

View file

@ -41,6 +41,7 @@ export * from './filter';
export * from './group'; export * from './group';
export * from './group-member'; export * from './group-member';
export * from './group-relationship'; export * from './group-relationship';
export * from './grouped-notifications-results';
export * from './instance'; export * from './instance';
export * from './interaction-policy'; export * from './interaction-policy';
export * from './interaction-request'; export * from './interaction-request';

View file

@ -619,6 +619,15 @@ const getFeatures = (instance: Instance) => {
v.software === PLEROMA, v.software === PLEROMA,
]), ]),
/**
* @see GET /api/v2/notifications/:group_key
* @see GET /api/v2/notifications/:group_key
* @see POST /api/v2/notifications/:group_key/dismiss
* @see GET /api/v2/notifications/:group_key/accounts
* @see GET /api/v2/notifications/unread_count
*/
groupedNotifications: instance.api_versions.mastodon >= 2,
/** /**
* Groups. * Groups.
* @see POST /api/v1/groups * @see POST /api/v1/groups

View file

@ -0,0 +1,31 @@
import type { PaginationParams } from './common';
interface GetGroupedNotificationsParams extends PaginationParams {
/** Types to include in the result. */
types?: Array<string>;
/** Types to exclude from the results. */
exclude_types?: Array<string>;
/** Return only notifications received from the specified account. */
acccount_id?: string;
/** One of `full` (default) or `partial_avatars`. When set to `partial_avatars`, some accounts will not be rendered in full in the returned `accounts` list but will be instead returned in stripped-down form in the `partial_accounts` list. The most recent account in a notification group is always rendered in full in the `accounts` attribute. */
expand_accounts?: 'full' | 'partial_avatars';
/** Restrict which notification types can be grouped. Use this if there are notification types for which your client does not support grouping. If omitted, the server will group notifications of all types it supports (currently, `favourite`, `follow` and `reblog`). If you do not want any notification grouping, use GET `/api/v1/notifications` instead. Notifications that would be grouped if not for this parameter will instead be returned as individual single-notification groups with a unique `group_key` that can be assumed to be of the form `ungrouped-{notification_id}`. Please note that neither the streaming API nor the individual notification APIs are aware of this parameter and will always include a “proper” `group_key` that can be different from what is returned here, meaning that you may have to ignore `group_key` for such notifications that you do not want grouped and use `ungrouped-{notification_id}` instead for consistency. */
grouped_types?: Array<string>;
/** Whether to include notifications filtered by the users NotificationPolicy. Defaults to false. */
include_filtered?: boolean;
}
interface GetUnreadNotificationGroupCountParams {
/** Maximum number of results to return. Defaults to 100 notifications. Max 1000 notifications. */
limit?: number;
/** Types of notifications that should count towards unread notifications. */
types?: Array<string>;
/** Types of notifications that should not count towards unread notifications. */
exclude_types?: Array<string>;
/** Only count unread notifications received from the specified account. */
account_id?: string;
/** Restrict which notification types can be grouped. Use this if there are notification types for which your client does not support grouping. If omitted, the server will group notifications of all types it supports (currently, `favourite`, `follow` and `reblog`). If you do not want any notification grouping, use GET /api/v1/notifications/unread_count instead. */
grouped_types?: Array<string>;
}
export type { GetGroupedNotificationsParams, GetUnreadNotificationGroupCountParams };

View file

@ -4,6 +4,7 @@ export * from './apps';
export * from './chats'; export * from './chats';
export * from './events'; export * from './events';
export * from './filtering'; export * from './filtering';
export * from './grouped-notifications';
export * from './groups'; export * from './groups';
export * from './instance'; export * from './instance';
export * from './interaction-requests'; export * from './interaction-requests';

View file

@ -1,11 +1,14 @@
interface PaginatedResponse<T> { interface PaginatedSingleResponse<T> {
previous: (() => Promise<PaginatedResponse<T>>) | null; previous: (() => Promise<PaginatedSingleResponse<T>>) | null;
next: (() => Promise<PaginatedResponse<T>>) | null; next: (() => Promise<PaginatedSingleResponse<T>>) | null;
items: Array<T>; items: T;
partial: boolean; partial: boolean;
total?: number; total?: number;
} }
type PaginatedResponse<T> = PaginatedSingleResponse<Array<T>>;
export type { export type {
PaginatedSingleResponse,
PaginatedResponse, PaginatedResponse,
}; };

View file

@ -1,6 +1,6 @@
{ {
"name": "pl-api", "name": "pl-api",
"version": "0.1.6", "version": "0.1.7",
"type": "module", "type": "module",
"homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api", "homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api",
"repository": { "repository": {
@ -20,6 +20,7 @@
"devDependencies": { "devDependencies": {
"@stylistic/eslint-plugin": "^2.8.0", "@stylistic/eslint-plugin": "^2.8.0",
"@types/http-link-header": "^1.0.7", "@types/http-link-header": "^1.0.7",
"@types/lodash.omit": "^4.5.9",
"@types/lodash.pick": "^4.4.9", "@types/lodash.pick": "^4.4.9",
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
@ -41,6 +42,7 @@
"dependencies": { "dependencies": {
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"http-link-header": "^1.1.3", "http-link-header": "^1.1.3",
"lodash.omit": "^4.5.0",
"lodash.pick": "^4.4.0", "lodash.pick": "^4.4.0",
"object-to-formdata": "^4.5.1", "object-to-formdata": "^4.5.1",
"query-string": "^9.1.0", "query-string": "^9.1.0",

View file

@ -478,6 +478,13 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash.omit@^4.5.9":
version "4.5.9"
resolved "https://registry.yarnpkg.com/@types/lodash.omit/-/lodash.omit-4.5.9.tgz#cf4744d034961406d6dc41d9cd109773a9ed8fe3"
integrity sha512-zuAVFLUPJMOzsw6yawshsYGgq2hWUHtsZgeXHZmSFhaQQFC6EQ021uDKHkSjOpNhSvtNSU9165/o3o/Q51GpTw==
dependencies:
"@types/lodash" "*"
"@types/lodash.pick@^4.4.9": "@types/lodash.pick@^4.4.9":
version "4.4.9" version "4.4.9"
resolved "https://registry.yarnpkg.com/@types/lodash.pick/-/lodash.pick-4.4.9.tgz#06f7d88faa81a6c5665584778aea7b1374a1dc5b" resolved "https://registry.yarnpkg.com/@types/lodash.pick/-/lodash.pick-4.4.9.tgz#06f7d88faa81a6c5665584778aea7b1374a1dc5b"
@ -2002,6 +2009,11 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.omit@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"
integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==
lodash.pick@^4.4.0: lodash.pick@^4.4.0:
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"

View file

@ -102,7 +102,7 @@
"mini-css-extract-plugin": "^2.9.1", "mini-css-extract-plugin": "^2.9.1",
"multiselect-react-dropdown": "^2.0.25", "multiselect-react-dropdown": "^2.0.25",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"pl-api": "^0.1.5", "pl-api": "^0.1.7",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"process": "^0.11.10", "process": "^0.11.10",
"punycode": "^2.1.1", "punycode": "^2.1.1",

View file

@ -47,11 +47,11 @@ const externalAuthorize = (instance: Instance, baseURL: string) =>
const scopes = getInstanceScopes(instance); const scopes = getInstanceScopes(instance);
return dispatch(createExternalApp(instance, baseURL)).then((app) => { return dispatch(createExternalApp(instance, baseURL)).then((app) => {
const { client_id, redirect_uri } = app as Record<string, string>; const { client_id, redirect_uri } = app;
const query = new URLSearchParams({ const query = new URLSearchParams({
client_id, client_id,
redirect_uri, redirect_uri: redirect_uri || app.redirect_uris[0]!,
response_type: 'code', response_type: 'code',
scope: scopes, scope: scopes,
}); });

View file

@ -4,7 +4,7 @@ import { defineMessages } from 'react-intl';
import { getClient } from 'pl-fe/api'; import { getClient } from 'pl-fe/api';
import { getNotificationStatus } from 'pl-fe/features/notifications/components/notification'; import { getNotificationStatus } from 'pl-fe/features/notifications/components/notification';
import { normalizeNotification, normalizeNotifications, type Notification } from 'pl-fe/normalizers/notification'; import { normalizeNotification } from 'pl-fe/normalizers/notification';
import { getFilters, regexFromFilters } from 'pl-fe/selectors'; import { getFilters, regexFromFilters } from 'pl-fe/selectors';
import { useSettingsStore } from 'pl-fe/stores/settings'; import { useSettingsStore } from 'pl-fe/stores/settings';
import { isLoggedIn } from 'pl-fe/utils/auth'; import { isLoggedIn } from 'pl-fe/utils/auth';
@ -18,7 +18,7 @@ import { importEntities } from './importer';
import { saveMarker } from './markers'; import { saveMarker } from './markers';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
import type { Account, Notification as BaseNotification, PaginatedResponse, Status } from 'pl-api'; import type { Notification as BaseNotification, GetGroupedNotificationsParams, GroupedNotificationsResults, NotificationGroup, PaginatedSingleResponse } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store'; import type { AppDispatch, RootState } from 'pl-fe/store';
const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE' as const; const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE' as const;
@ -58,8 +58,8 @@ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
}); });
const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: Array<BaseNotification>) => { const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: Array<NotificationGroup>) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.sample_account_ids).flat();
if (accountIds.length > 0) { if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds)); dispatch(fetchRelationships(accountIds));
@ -76,13 +76,16 @@ const updateNotifications = (notification: BaseNotification) =>
statuses: [getNotificationStatus(notification)], statuses: [getNotificationStatus(notification)],
})); }));
if (showInColumn) { if (showInColumn) {
const normalizedNotification = normalizeNotification(notification);
dispatch({ dispatch({
type: NOTIFICATIONS_UPDATE, type: NOTIFICATIONS_UPDATE,
notification: normalizeNotification(notification), notification: normalizedNotification,
}); });
fetchRelatedRelationships(dispatch, [notification]); fetchRelatedRelationships(dispatch, [normalizedNotification]);
} }
}; };
@ -195,7 +198,7 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
} }
} }
const params: Record<string, any> = { const params: GetGroupedNotificationsParams = {
max_id: maxId, max_id: maxId,
}; };
@ -203,7 +206,7 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
if (features.notificationsIncludeTypes) { if (features.notificationsIncludeTypes) {
params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any)); params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any));
} else { } else {
params.exclude_types = EXCLUDE_TYPES; params.exclude_types = [...EXCLUDE_TYPES];
} }
} else { } else {
const filtered = FILTER_TYPES[activeFilter] || [activeFilter]; const filtered = FILTER_TYPES[activeFilter] || [activeFilter];
@ -215,51 +218,65 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
} }
if (!maxId && notifications.items.size > 0) { if (!maxId && notifications.items.size > 0) {
params.since_id = notifications.getIn(['items', 0, 'id']); params.since_id = notifications.items.first()?.page_max_id;
} }
dispatch(expandNotificationsRequest()); dispatch(expandNotificationsRequest());
return getClient(state).notifications.getNotifications(params, { signal: abortExpandNotifications.signal }).then(response => { return getClient(state).groupedNotifications.getGroupedNotifications(params, { signal: abortExpandNotifications.signal }).then(({ items: { accounts, statuses, notification_groups }, next }) => {
const entries = (response.items).reduce((acc, item) => {
if (item.account?.id) {
acc.accounts[item.account.id] = item.account;
}
// Used by Move notification
if (item.type === 'move' && item.target.id) {
acc.accounts[item.target.id] = item.target;
}
// TODO actually check for type
// @ts-ignore
if (item.status?.id) {
// @ts-ignore
acc.statuses[item.status.id] = item.status;
}
return acc;
}, { accounts: {}, statuses: {} } as { accounts: Record<string, Account>; statuses: Record<string, Status> });
dispatch(importEntities({ dispatch(importEntities({
accounts: Object.values(entries.accounts), accounts,
statuses: Object.values(entries.statuses), statuses,
})); }));
const deduplicatedNotifications = normalizeNotifications(response.items, state.notifications.items); dispatch(expandNotificationsSuccess(notification_groups, next));
fetchRelatedRelationships(dispatch, notification_groups);
dispatch(expandNotificationsSuccess(deduplicatedNotifications, response.next));
fetchRelatedRelationships(dispatch, response.items);
done(); done();
}).catch(error => { }).catch(error => {
dispatch(expandNotificationsFail(error)); dispatch(expandNotificationsFail(error));
done(); done();
}); });
// return getClient(state).notifications.getNotifications(params, { signal: abortExpandNotifications.signal }).then(response => {
// const entries = (response.items).reduce((acc, item) => {
// if (item.account?.id) {
// acc.accounts[item.account.id] = item.account;
// }
// // Used by Move notification
// if (item.type === 'move' && item.target.id) {
// acc.accounts[item.target.id] = item.target;
// }
// // TODO actually check for type
// // @ts-ignore
// if (item.status?.id) {
// // @ts-ignore
// acc.statuses[item.status.id] = item.status;
// }
// return acc;
// }, { accounts: {}, statuses: {} } as { accounts: Record<string, Account>; statuses: Record<string, Status> });
// dispatch(importEntities({
// accounts: Object.values(entries.accounts),
// statuses: Object.values(entries.statuses),
// }));
// const deduplicatedNotifications = normalizeNotifications(response.items, state.notifications.items);
// dispatch(expandNotificationsSuccess(deduplicatedNotifications, response.next));
// fetchRelatedRelationships(dispatch, response.items);
// done();
// }).catch(error => {
// dispatch(expandNotificationsFail(error));
// done();
// });
}; };
const expandNotificationsRequest = () => ({ type: NOTIFICATIONS_EXPAND_REQUEST }); const expandNotificationsRequest = () => ({ type: NOTIFICATIONS_EXPAND_REQUEST });
const expandNotificationsSuccess = (notifications: Array<Notification>, next: (() => Promise<PaginatedResponse<BaseNotification>>) | null) => ({ const expandNotificationsSuccess = (notifications: Array<NotificationGroup>, next: (() => Promise<PaginatedSingleResponse<GroupedNotificationsResults>>) | null) => ({
type: NOTIFICATIONS_EXPAND_SUCCESS, type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications, notifications,
next, next,
@ -297,7 +314,7 @@ const markReadNotifications = () =>
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const state = getState(); const state = getState();
const topNotificationId = state.notifications.items.first()?.id; const topNotificationId = state.notifications.items.first()?.page_max_id;
const lastReadId = state.notifications.lastRead; const lastReadId = state.notifications.lastRead;
if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) { if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) {

View file

@ -13,8 +13,6 @@ import type { AppDispatch, RootState } from 'pl-fe/store';
const FE_NAME = 'pl_fe'; const FE_NAME = 'pl_fe';
const getAccount = makeGetAccount();
/** Options when changing/saving settings. */ /** Options when changing/saving settings. */
type SettingOpts = { type SettingOpts = {
/** Whether to display an alert when settings are saved. */ /** Whether to display an alert when settings are saved. */
@ -71,7 +69,7 @@ const updateSettingsStore = (settings: any) =>
}, },
})); }));
} else { } else {
const accountUrl = getAccount(state, state.me as string)!.url; const accountUrl = makeGetAccount()(state, state.me as string)!.url;
return updateAuthAccount(accountUrl, settings); return updateAuthAccount(accountUrl, settings);
} }

View file

@ -16,7 +16,7 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account'; import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import { getBaseURL } from 'pl-fe/utils/accounts'; import { getBaseURL } from 'pl-fe/utils/accounts';
import type { Token } from 'pl-api'; import type { CredentialApplication, Token } from 'pl-api';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.app_create', defaultMessage: 'Create app' }, heading: { id: 'column.app_create', defaultMessage: 'Create app' },
@ -53,7 +53,7 @@ const CreateApp: React.FC = () => {
}); });
}; };
const handleCreateToken = (app: Record<string, string>) => { const handleCreateToken = (app: CredentialApplication) => {
const baseURL = getBaseURL(account!); const baseURL = getBaseURL(account!);
const tokenParams = { const tokenParams = {

View file

@ -24,11 +24,9 @@ import { useModalsStore } from 'pl-fe/stores/modals';
import { useSettingsStore } from 'pl-fe/stores/settings'; import { useSettingsStore } from 'pl-fe/stores/settings';
import { NotificationType } from 'pl-fe/utils/notification'; import { NotificationType } from 'pl-fe/utils/notification';
import type { Notification as BaseNotification } from 'pl-api'; import type { NotificationGroup } from 'pl-api';
import type { Account } from 'pl-fe/normalizers/account'; import type { Account } from 'pl-fe/normalizers/account';
import type { Notification as NotificationEntity } from 'pl-fe/normalizers/notification';
import type { Status as StatusEntity } from 'pl-fe/normalizers/status'; import type { Status as StatusEntity } from 'pl-fe/normalizers/status';
import type { MinifiedNotification } from 'pl-fe/reducers/notifications';
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: string) => { const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: string) => {
const output = [message]; const output = [message];
@ -184,13 +182,13 @@ const avatarSize = 48;
interface INotification { interface INotification {
hidden?: boolean; hidden?: boolean;
notification: MinifiedNotification; notification: NotificationGroup;
onMoveUp?: (notificationId: string) => void; onMoveUp?: (notificationId: string) => void;
onMoveDown?: (notificationId: string) => void; onMoveDown?: (notificationId: string) => void;
onReblog?: (status: StatusEntity, e?: KeyboardEvent) => void; onReblog?: (status: StatusEntity, e?: KeyboardEvent) => void;
} }
const getNotificationStatus = (n: NotificationEntity | BaseNotification) => { const getNotificationStatus = (n: Pick<NotificationGroup, 'type'> & ({ status: StatusEntity } | { })) => {
if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(n.type)) if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(n.type))
// @ts-ignore // @ts-ignore
return n.status; return n.status;
@ -207,15 +205,17 @@ const Notification: React.FC<INotification> = (props) => {
const { me } = useLoggedIn(); const { me } = useLoggedIn();
const { openModal } = useModalsStore(); const { openModal } = useModalsStore();
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const notification = useAppSelector((state) => getNotification(state, props.notification)); const notification = useAppSelector((state) => getNotification(state, props.notification));
const status = getNotificationStatus(notification);
const history = useHistory(); const history = useHistory();
const intl = useIntl(); const intl = useIntl();
const instance = useInstance(); const instance = useInstance();
const type = notification.type; const type = notification.type;
const { account, accounts } = notification; const { accounts } = notification;
const status = getNotificationStatus(notification); const account = accounts[0];
const getHandlers = () => ({ const getHandlers = () => ({
reply: handleMention, reply: handleMention,
@ -289,13 +289,13 @@ const Notification: React.FC<INotification> = (props) => {
const handleMoveUp = () => { const handleMoveUp = () => {
if (onMoveUp) { if (onMoveUp) {
onMoveUp(notification.id); onMoveUp(notification.group_key);
} }
}; };
const handleMoveDown = () => { const handleMoveDown = () => {
if (onMoveDown) { if (onMoveDown) {
onMoveDown(notification.id); onMoveDown(notification.group_key);
} }
}; };
@ -393,7 +393,7 @@ const Notification: React.FC<INotification> = (props) => {
name: account && typeof account === 'object' ? account.acct : '', name: account && typeof account === 'object' ? account.acct : '',
targetName, targetName,
}), }),
notification.created_at, notification.latest_page_notification_at!,
) )
); );
@ -433,7 +433,7 @@ const Notification: React.FC<INotification> = (props) => {
truncate truncate
data-testid='message' data-testid='message'
> >
<RelativeTimestamp timestamp={notification.created_at} theme='muted' size='sm' className='whitespace-nowrap' /> <RelativeTimestamp timestamp={notification.latest_page_notification_at!} theme='muted' size='sm' className='whitespace-nowrap' />
</Text> </Text>
</div> </div>
)} )}

View file

@ -32,7 +32,7 @@ const messages = defineMessages({
const getNotifications = createSelector([ const getNotifications = createSelector([
(state: RootState) => state.notifications.items.toList(), (state: RootState) => state.notifications.items.toList(),
], (notifications) => notifications.filter(item => item !== null && !item.duplicate)); ], (notifications) => notifications.filter(item => item !== null));
const Notifications = () => { const Notifications = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -54,8 +54,13 @@ const Notifications = () => {
// }; // };
const handleLoadOlder = useCallback(debounce(() => { const handleLoadOlder = useCallback(debounce(() => {
const last = notifications.last(); const minId = notifications.reduce<string | undefined>(
dispatch(expandNotifications({ maxId: last && last.id })); (minId, notification) => minId && notification.page_min_id && notification.page_min_id > minId
? minId
: notification.page_min_id,
undefined,
);
dispatch(expandNotifications({ maxId: minId }));
}, 300, { leading: true }), [notifications]); }, 300, { leading: true }), [notifications]);
const handleScroll = useCallback(debounce((startIndex?: number) => { const handleScroll = useCallback(debounce((startIndex?: number) => {
@ -63,12 +68,12 @@ const Notifications = () => {
}, 100), []); }, 100), []);
const handleMoveUp = (id: string) => { const handleMoveUp = (id: string) => {
const elementIndex = notifications.findIndex(item => item !== null && item.id === id) - 1; const elementIndex = notifications.findIndex(item => item !== null && item.group_key === id) - 1;
_selectChild(elementIndex); _selectChild(elementIndex);
}; };
const handleMoveDown = (id: string) => { const handleMoveDown = (id: string) => {
const elementIndex = notifications.findIndex(item => item !== null && item.id === id) + 1; const elementIndex = notifications.findIndex(item => item !== null && item.group_key === id) + 1;
_selectChild(elementIndex); _selectChild(elementIndex);
}; };
@ -111,7 +116,7 @@ const Notifications = () => {
} else if (notifications.size > 0 || hasMore) { } else if (notifications.size > 0 || hasMore) {
scrollableContent = notifications.map((item) => ( scrollableContent = notifications.map((item) => (
<Notification <Notification
key={item.id} key={item.group_key}
notification={item} notification={item}
onMoveUp={handleMoveUp} onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown} onMoveDown={handleMoveDown}

View file

@ -1,61 +1,20 @@
import { getNotificationStatus } from 'pl-fe/features/notifications/components/notification'; import omit from 'lodash/omit';
import { normalizeAccount } from './account'; import type { Notification as BaseNotification, NotificationGroup } from 'pl-api';
import type { OrderedMap as ImmutableOrderedMap } from 'immutable'; const normalizeNotification = (notification: BaseNotification): NotificationGroup => ({
import type { Notification as BaseNotification } from 'pl-api'; ...(omit(notification, ['account', 'status', 'target'])),
import type { MinifiedNotification } from 'pl-fe/reducers/notifications'; group_key: notification.id,
notifications_count: 1,
const STATUS_NOTIFICATION_TYPES = [ most_recent_notification_id: notification.id,
'favourite', page_min_id: notification.id,
'reblog', page_max_id: notification.id,
'emoji_reaction', latest_page_notification_at: notification.created_at,
'event_reminder', sample_account_ids: [notification.account.id],
'participation_accepted', // @ts-ignore
'participation_request', status_id: notification.status?.id,
]; // @ts-ignore
target_id: notification.target?.id,
const normalizeNotification = (notification: BaseNotification) => ({
...notification,
duplicate: false,
account: normalizeAccount(notification.account),
account_id: notification.account.id,
accounts: [normalizeAccount(notification.account)],
account_ids: [notification.account.id],
}); });
const normalizeNotifications = (notifications: Array<BaseNotification>, stateNotifications?: ImmutableOrderedMap<string, MinifiedNotification>) => { export { normalizeNotification };
const deduplicatedNotifications: Notification[] = [];
for (const notification of notifications) {
const existingNotification = stateNotifications?.get(notification.id);
// Do not update grouped notifications
if (existingNotification && (existingNotification.duplicate || existingNotification.account_ids.length)) continue;
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(normalizeAccount(notification.account));
existingNotification.account_ids.push(notification.account.id);
deduplicatedNotifications.push({ ...normalizeNotification(notification), duplicate: true });
} else {
deduplicatedNotifications.push(normalizeNotification(notification));
}
} else {
deduplicatedNotifications.push(normalizeNotification(notification));
}
}
return deduplicatedNotifications;
};
type Notification = ReturnType<typeof normalizeNotification>;
export { normalizeNotification, normalizeNotifications, type Notification };

View file

@ -1,6 +1,6 @@
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import { applicationSchema, PlApiClient, tokenSchema, type Application, type CredentialAccount, type Token } from 'pl-api'; import { applicationSchema, PlApiClient, tokenSchema, type CredentialAccount, type CredentialApplication, type Token } from 'pl-api';
import * as v from 'valibot'; import * as v from 'valibot';
import { MASTODON_PRELOAD_IMPORT, type PreloadAction } from 'pl-fe/actions/preload'; import { MASTODON_PRELOAD_IMPORT, type PreloadAction } from 'pl-fe/actions/preload';
@ -33,7 +33,7 @@ const AuthUserRecord = ImmutableRecord({
}); });
const ReducerRecord = ImmutableRecord({ const ReducerRecord = ImmutableRecord({
app: null as Application | null, app: null as CredentialApplication | null,
tokens: ImmutableMap<string, Token>(), tokens: ImmutableMap<string, Token>(),
users: ImmutableMap<string, AuthUser>(), users: ImmutableMap<string, AuthUser>(),
me: null as string | null, me: null as string | null,
@ -145,7 +145,10 @@ const sanitizeState = (state: State) => {
}); });
}; };
const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.delete('client').toJS())); const persistAuth = (state: State) => {
const { client, ...data } = state.toJS();
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
};
const persistSession = (state: State) => { const persistSession = (state: State) => {
const me = state.me; const me = state.me;

View file

@ -1,5 +1,4 @@
import { Record as ImmutableRecord, OrderedMap as ImmutableOrderedMap } from 'immutable'; import { Record as ImmutableRecord, OrderedMap as ImmutableOrderedMap } from 'immutable';
import omit from 'lodash/omit';
import { import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
@ -27,8 +26,7 @@ import {
} from '../actions/notifications'; } from '../actions/notifications';
import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines'; import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines';
import type { AccountWarning, Notification as BaseNotification, Markers, PaginatedResponse, Relationship, RelationshipSeveranceEvent, Report } from 'pl-api'; import type { Notification as BaseNotification, Markers, NotificationGroup, PaginatedResponse, Relationship } from 'pl-api';
import type { Notification } from 'pl-fe/normalizers/notification';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
const QueuedNotificationRecord = ImmutableRecord({ const QueuedNotificationRecord = ImmutableRecord({
@ -38,7 +36,7 @@ const QueuedNotificationRecord = ImmutableRecord({
}); });
const ReducerRecord = ImmutableRecord({ const ReducerRecord = ImmutableRecord({
items: ImmutableOrderedMap<string, MinifiedNotification>(), items: ImmutableOrderedMap<string, NotificationGroup>(),
hasMore: true, hasMore: true,
top: false, top: false,
unread: 0, unread: 0,
@ -54,103 +52,103 @@ type QueuedNotification = ReturnType<typeof QueuedNotificationRecord>;
const parseId = (id: string | number) => parseInt(id as string, 10); const parseId = (id: string | number) => parseInt(id as string, 10);
// For sorting the notifications // For sorting the notifications
const comparator = (a: Pick<Notification, 'id'>, b: Pick<Notification, 'id'>) => { const comparator = (a: Pick<NotificationGroup, 'group_key'>, b: Pick<NotificationGroup, 'group_key'>) => {
const parse = (m: Pick<Notification, 'id'>) => parseId(m.id); const parse = (m: Pick<NotificationGroup, 'group_key'>) => parseId(m.group_key);
if (parse(a) < parse(b)) return 1; if (parse(a) < parse(b)) return 1;
if (parse(a) > parse(b)) return -1; if (parse(a) > parse(b)) return -1;
return 0; return 0;
}; };
const minifyNotification = (notification: Notification) => { // const minifyNotification = (notification: Notification) => {
// @ts-ignore // // @ts-ignore
const minifiedNotification: { // const minifiedNotification: {
duplicate: boolean; // duplicate: boolean;
account_id: string; // account_id: string;
account_ids: string[]; // account_ids: string[];
created_at: string; // created_at: string;
id: string; // id: string;
} & ( // } & (
| { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' } // | { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' }
| { // | {
type: 'mention' | 'status' | 'reblog' | 'favourite' | 'poll' | 'update' | 'event_reminder'; // type: 'mention' | 'status' | 'reblog' | 'favourite' | 'poll' | 'update' | 'event_reminder';
status_id: string; // status_id: string;
} // }
| { // | {
type: 'admin.report'; // type: 'admin.report';
report: Report; // report: Report;
} // }
| { // | {
type: 'severed_relationships'; // type: 'severed_relationships';
relationship_severance_event: RelationshipSeveranceEvent; // relationship_severance_event: RelationshipSeveranceEvent;
} // }
| { // | {
type: 'moderation_warning'; // type: 'moderation_warning';
moderation_warning: AccountWarning; // moderation_warning: AccountWarning;
} // }
| { // | {
type: 'move'; // type: 'move';
target_id: string; // target_id: string;
} // }
| { // | {
type: 'emoji_reaction'; // type: 'emoji_reaction';
emoji: string; // emoji: string;
emoji_url: string | null; // emoji_url: string | null;
status_id: string; // status_id: string;
} // }
| { // | {
type: 'chat_mention'; // type: 'chat_mention';
chat_message_id: string; // chat_message_id: string;
} // }
| { // | {
type: 'participation_accepted' | 'participation_request'; // type: 'participation_accepted' | 'participation_request';
status_id: string; // status_id: string;
participation_message: string | null; // participation_message: string | null;
} // }
) = { // ) = {
...omit(notification, ['account', 'accounts']), // ...omit(notification, ['account', 'accounts']),
created_at: notification.created_at, // created_at: notification.latest_page_notification_at,
id: notification.id, // id: notification.id,
type: notification.type, // type: notification.type,
}; // };
// @ts-ignore // // @ts-ignore
if (notification.status) minifiedNotification.status_id = notification.status.id; // if (notification.status) minifiedNotification.status_id = notification.status.id;
// @ts-ignore // // @ts-ignore
if (notification.target) minifiedNotification.target_id = notification.target.id; // if (notification.target) minifiedNotification.target_id = notification.target.id;
// @ts-ignore // // @ts-ignore
if (notification.chat_message) minifiedNotification.chat_message_id = notification.chat_message.id; // if (notification.chat_message) minifiedNotification.chat_message_id = notification.chat_message.id;
return minifiedNotification; // return minifiedNotification;
}; // };
type MinifiedNotification = ReturnType<typeof minifyNotification>; // type MinifiedNotification = ReturnType<typeof minifyNotification>;
// Count how many notifications appear after the given ID (for unread count) // Count how many notifications appear after the given ID (for unread count)
const countFuture = (notifications: ImmutableOrderedMap<string, MinifiedNotification>, lastId: string | number) => const countFuture = (notifications: ImmutableOrderedMap<string, NotificationGroup>, lastId: string | number) =>
notifications.reduce((acc, notification) => { notifications.reduce((acc, notification) => {
if (!notification.duplicate && parseId(notification.id) > parseId(lastId)) { if (parseId(notification.group_key) > parseId(lastId)) {
return acc + 1; return acc + 1;
} else { } else {
return acc; return acc;
} }
}, 0); }, 0);
const importNotification = (state: State, notification: Notification) => { const importNotification = (state: State, notification: NotificationGroup) => {
const top = state.top; const top = state.top;
if (!top && !notification.duplicate) state = state.update('unread', unread => unread + 1); if (!top) state = state.update('unread', unread => unread + 1);
return state.update('items', map => { return state.update('items', map => {
if (top && map.size > 40) { if (top && map.size > 40) {
map = map.take(20); map = map.take(20);
} }
return map.set(notification.id, minifyNotification(notification)).sort(comparator); return map.set(notification.group_key, notification).sort(comparator);
}); });
}; };
const expandNormalizedNotifications = (state: State, notifications: Notification[], next: (() => Promise<PaginatedResponse<BaseNotification>>) | null) => { const expandNormalizedNotifications = (state: State, notifications: NotificationGroup[], next: (() => Promise<PaginatedResponse<BaseNotification>>) | null) => {
const items = ImmutableOrderedMap(notifications.map(minifyNotification).map(n => [n.id, n])); const items = ImmutableOrderedMap(notifications.map(n => [n.group_key, n]));
return state.withMutations(mutable => { return state.withMutations(mutable => {
mutable.update('items', map => map.merge(items).sort(comparator)); mutable.update('items', map => map.merge(items).sort(comparator));
@ -161,10 +159,10 @@ const expandNormalizedNotifications = (state: State, notifications: Notification
}; };
const filterNotifications = (state: State, relationship: Relationship) => const filterNotifications = (state: State, relationship: Relationship) =>
state.update('items', map => map.filterNot(item => item !== null && item.account_ids.includes(relationship.id))); state.update('items', map => map.filterNot(item => item !== null && item.sample_account_ids.includes(relationship.id)));
const filterNotificationIds = (state: State, accountIds: Array<string>, type?: string) => { const filterNotificationIds = (state: State, accountIds: Array<string>, type?: string) => {
const helper = (list: ImmutableOrderedMap<string, MinifiedNotification>) => list.filterNot(item => item !== null && accountIds.includes(item.account_ids[0]) && (type === undefined || type === item.type)); const helper = (list: ImmutableOrderedMap<string, NotificationGroup>) => list.filterNot(item => item !== null && accountIds.includes(item.sample_account_ids[0]) && (type === undefined || type === item.type));
return state.update('items', helper); return state.update('items', helper);
}; };
@ -182,14 +180,14 @@ const updateNotificationsQueue = (state: State, notification: BaseNotification,
const listedNotifications = state.items; const listedNotifications = state.items;
const totalQueuedNotificationsCount = state.totalQueuedNotificationsCount; const totalQueuedNotificationsCount = state.totalQueuedNotificationsCount;
const alreadyExists = queuedNotifications.has(notification.id) || listedNotifications.has(notification.id); const alreadyExists = queuedNotifications.has(notification.group_key) || listedNotifications.has(notification.group_key);
if (alreadyExists) return state; if (alreadyExists) return state;
const newQueuedNotifications = queuedNotifications; const newQueuedNotifications = queuedNotifications;
return state.withMutations(mutable => { return state.withMutations(mutable => {
if (totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { if (totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) {
mutable.set('queuedNotifications', newQueuedNotifications.set(notification.id, QueuedNotificationRecord({ mutable.set('queuedNotifications', newQueuedNotifications.set(notification.group_key, QueuedNotificationRecord({
notification, notification,
intlMessages, intlMessages,
intlLocale, intlLocale,
@ -259,7 +257,4 @@ const notifications = (state: State = ReducerRecord(), action: AnyAction | Timel
} }
}; };
export { export { notifications as default };
notifications as default,
type MinifiedNotification,
};

View file

@ -13,12 +13,10 @@ import { validId } from 'pl-fe/utils/auth';
import ConfigDB from 'pl-fe/utils/config-db'; import ConfigDB from 'pl-fe/utils/config-db';
import { shouldFilter } from 'pl-fe/utils/timelines'; import { shouldFilter } from 'pl-fe/utils/timelines';
import type { Account as BaseAccount, Filter, MediaAttachment, Relationship } from 'pl-api'; import type { Account as BaseAccount, Filter, MediaAttachment, NotificationGroup, Relationship } from 'pl-api';
import type { EntityStore } from 'pl-fe/entity-store/types'; import type { EntityStore } from 'pl-fe/entity-store/types';
import type { Account } from 'pl-fe/normalizers/account'; import type { Account } from 'pl-fe/normalizers/account';
import type { Group } from 'pl-fe/normalizers/group'; import type { Group } from 'pl-fe/normalizers/group';
import type { Notification } from 'pl-fe/normalizers/notification';
import type { MinifiedNotification } from 'pl-fe/reducers/notifications';
import type { MinifiedStatus } from 'pl-fe/reducers/statuses'; import type { MinifiedStatus } from 'pl-fe/reducers/statuses';
import type { MRFSimple } from 'pl-fe/schemas/pleroma'; import type { MRFSimple } from 'pl-fe/schemas/pleroma';
import type { RootState } from 'pl-fe/store'; import type { RootState } from 'pl-fe/store';
@ -27,7 +25,9 @@ const selectAccount = (state: RootState, accountId: string) =>
state.entities[Entities.ACCOUNTS]?.store[accountId] as Account | undefined; state.entities[Entities.ACCOUNTS]?.store[accountId] as Account | undefined;
const selectAccounts = (state: RootState, accountIds: Array<string>) => const selectAccounts = (state: RootState, accountIds: Array<string>) =>
accountIds.map(accountId => state.entities[Entities.ACCOUNTS]?.store[accountId] as Account | undefined); accountIds
.map(accountId => state.entities[Entities.ACCOUNTS]?.store[accountId] as Account | undefined)
.filter((account): account is Account => account !== undefined);
const selectOwnAccount = (state: RootState) => { const selectOwnAccount = (state: RootState) => {
if (state.me) { if (state.me) {
@ -177,26 +177,33 @@ const makeGetStatus = () => createSelector(
type SelectedStatus = Exclude<ReturnType<ReturnType<typeof makeGetStatus>>, null>; type SelectedStatus = Exclude<ReturnType<ReturnType<typeof makeGetStatus>>, null>;
const makeGetNotification = () => createSelector([ const makeGetNotification = () => createSelector([
(_state: RootState, notification: MinifiedNotification) => notification, (_state: RootState, notification: NotificationGroup) => notification,
// @ts-ignore // @ts-ignore
(state: RootState, notification: MinifiedNotification) => selectAccount(state, notification.account_id), (state: RootState, notification: NotificationGroup) => selectAccount(state, notification.target_id),
// @ts-ignore // @ts-ignore
(state: RootState, notification: MinifiedNotification) => selectAccount(state, notification.target_id), (state: RootState, notification: NotificationGroup) => state.statuses.get(notification.status_id),
// @ts-ignore (state: RootState, notification: NotificationGroup) => selectAccounts(state, notification.sample_account_ids),
(state: RootState, notification: MinifiedNotification) => state.statuses.get(notification.status_id), ], (notification, target, status, accounts): SelectedNotification => ({
(state: RootState, notification: MinifiedNotification) => notification.account_ids ? selectAccounts(state, notification.account_ids) : null,
], (notification, account, target, status, accounts): MinifiedNotification & Notification => ({
...notification, ...notification,
// @ts-ignore // @ts-ignore
account: account || null, target,
// @ts-ignore
target: target || null,
// @ts-ignore
status: status || null,
// @ts-ignore // @ts-ignore
status,
accounts, accounts,
})); }));
type SelectedNotification = NotificationGroup & {
accounts: Array<Account>;
} & ({
type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite';
} | {
type: 'status' | 'mention' | 'reblog' | 'favourite' | 'poll' | 'update' | 'emoji_reaction' | 'event_reminder' | 'participation_accepted' | 'participation_request';
status: MinifiedStatus;
} | {
type: 'move';
target: Account;
})
type AccountGalleryAttachment = MediaAttachment & { type AccountGalleryAttachment = MediaAttachment & {
status: MinifiedStatus; status: MinifiedStatus;
account: BaseAccount; account: BaseAccount;
@ -357,6 +364,7 @@ export {
makeGetStatus, makeGetStatus,
type SelectedStatus, type SelectedStatus,
makeGetNotification, makeGetNotification,
type SelectedNotification,
type AccountGalleryAttachment, type AccountGalleryAttachment,
getAccountGallery, getAccountGallery,
getGroupGallery, getGroupGallery,

View file

@ -6651,6 +6651,11 @@ lodash.merge@^4.6.0, lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.omit@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"
integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==
lodash.once@^4.0.0: lodash.once@^4.0.0:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
@ -7570,13 +7575,14 @@ pkg-dir@^4.1.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
pl-api@^0.1.5: pl-api@^0.1.7:
version "0.1.5" version "0.1.7"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.1.5.tgz#373d58fc40ae23b12c6d20def8d3332112e2dd93" resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.1.7.tgz#929b557903b280bda9e7c7bf6aa8cd015698c42f"
integrity sha512-IMcwANPtTRMv+tTLW4ic/o/usYIHeapR+aOx5YhmmVQsXTiVUZQtCVLmrfIrqMnyXm8PHvE/arzzozRFU8+GXQ== integrity sha512-cTUHacufHDlNl7zHsOH+LW5MvotY5pUnBhfFMofVfvGOswURl0F1x1L8+V5k1D4VGn+mE7CnOv3esz/3QrrQmg==
dependencies: dependencies:
blurhash "^2.0.5" blurhash "^2.0.5"
http-link-header "^1.1.3" http-link-header "^1.1.3"
lodash.omit "^4.5.0"
lodash.pick "^4.4.0" lodash.pick "^4.4.0"
object-to-formdata "^4.5.1" object-to-formdata "^4.5.1"
query-string "^9.1.0" query-string "^9.1.0"