From 94b3af03e92c55b31a91832c9c7d02d0e72ca725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 1 Nov 2024 23:19:18 +0100 Subject: [PATCH 1/9] pl-api: Start implemenitng grouped notifications with fallback to v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-api/lib/client.ts | 75 ++++++++++++++++++- packages/pl-api/lib/entities/application.ts | 22 +++++- packages/pl-api/lib/features.ts | 9 +++ packages/pl-api/lib/responses.ts | 11 ++- packages/pl-api/package.json | 2 + packages/pl-api/yarn.lock | 12 +++ packages/pl-fe/src/actions/external-auth.ts | 4 +- packages/pl-fe/src/actions/settings.ts | 4 +- .../src/features/developers/apps/create.tsx | 4 +- packages/pl-fe/src/reducers/auth.ts | 4 +- 10 files changed, 128 insertions(+), 19 deletions(-) diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 8039639c4..bcfe98eea 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -1,3 +1,4 @@ +import omit from 'lodash.omit'; import * as v from 'valibot'; import { @@ -27,6 +28,7 @@ import { contextSchema, conversationSchema, credentialAccountSchema, + credentialApplicationSchema, customEmojiSchema, domainBlockSchema, emojiReactionSchema, @@ -70,6 +72,7 @@ import { trendsLinkSchema, webPushSubscriptionSchema, } from './entities'; +import { GroupedNotificationsResults, groupedNotificationsResultsSchema, NotificationGroup } from './entities/grouped-notifications-results'; import { filteredArray } from './entities/utils'; import { AKKOMA, type Features, getFeatures, GOTOSOCIAL, MITRA } from './features'; import { @@ -107,6 +110,7 @@ import { MuteAccountParams, UpdateFilterParams, } from './params/filtering'; +import { GetGroupedNotificationsParams } from './params/grouped-notifications'; import { CreateGroupParams, GetGroupBlocksParams, @@ -195,6 +199,7 @@ import type { AdminReport, GroupRole, Instance, + Notification, PleromaConfig, Status, StreamingEvent, @@ -228,7 +233,9 @@ import type { AdminUpdateRuleParams, AdminUpdateStatusParams, } 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 @@ -353,6 +360,60 @@ class PlApiClient { }; }; + #groupNotifications = ({ previous, next, items, ...response }: PaginatedResponse, params?: GetGroupedNotificationsParams): PaginatedSingleResponse => { + const notificationGroups: Array = []; + + 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: items.map(({ account }) => account), + statuses: items.reduce>((statuses, notification) => { + if ('status' in notification) statuses.push(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. */ public readonly apps = { /** @@ -363,7 +424,7 @@ class PlApiClient { createApplication: async (params: CreateApplicationParams) => { 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 +2766,16 @@ class PlApiClient { }; + public readonly groupedNotifications = { + getGroupedNotifications: async (params: GetGroupedNotificationsParams) => { + if (this.features.groupedNotifications) { + return this.#paginatedGet('/api/v2/notifications', { params }, groupedNotificationsResultsSchema); + } else { + return this.#groupNotifications(await this.notifications.getNotifications(), params); + } + }, + }; + public readonly pushNotifications = { /** * Subscribe to push notifications diff --git a/packages/pl-api/lib/entities/application.ts b/packages/pl-api/lib/entities/application.ts index 333c9ccd5..1f96c0b67 100644 --- a/packages/pl-api/lib/entities/application.ts +++ b/packages/pl-api/lib/entities/application.ts @@ -12,9 +12,6 @@ const applicationSchema = v.pipe(v.any(), v.transform((application) => ({ })), v.object({ name: v.fallback(v.string(), ''), 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()), 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; -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; + +export { applicationSchema, credentialApplicationSchema, type Application, type CredentialApplication }; diff --git a/packages/pl-api/lib/features.ts b/packages/pl-api/lib/features.ts index 4998ff5bd..df8c45757 100644 --- a/packages/pl-api/lib/features.ts +++ b/packages/pl-api/lib/features.ts @@ -619,6 +619,15 @@ const getFeatures = (instance: Instance) => { 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. * @see POST /api/v1/groups diff --git a/packages/pl-api/lib/responses.ts b/packages/pl-api/lib/responses.ts index a50480071..34767248c 100644 --- a/packages/pl-api/lib/responses.ts +++ b/packages/pl-api/lib/responses.ts @@ -1,11 +1,14 @@ -interface PaginatedResponse { - previous: (() => Promise>) | null; - next: (() => Promise>) | null; - items: Array; +interface PaginatedSingleResponse { + previous: (() => Promise>) | null; + next: (() => Promise>) | null; + items: T; partial: boolean; total?: number; } +type PaginatedResponse = PaginatedSingleResponse>; + export type { + PaginatedSingleResponse, PaginatedResponse, }; diff --git a/packages/pl-api/package.json b/packages/pl-api/package.json index 6655ba035..6829a0e21 100644 --- a/packages/pl-api/package.json +++ b/packages/pl-api/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@stylistic/eslint-plugin": "^2.8.0", "@types/http-link-header": "^1.0.7", + "@types/lodash.omit": "^4.5.9", "@types/lodash.pick": "^4.4.9", "@types/node": "^22.7.4", "@types/semver": "^7.5.8", @@ -41,6 +42,7 @@ "dependencies": { "blurhash": "^2.0.5", "http-link-header": "^1.1.3", + "lodash.omit": "^4.5.0", "lodash.pick": "^4.4.0", "object-to-formdata": "^4.5.1", "query-string": "^9.1.0", diff --git a/packages/pl-api/yarn.lock b/packages/pl-api/yarn.lock index 2d51fdce1..3f559185b 100644 --- a/packages/pl-api/yarn.lock +++ b/packages/pl-api/yarn.lock @@ -478,6 +478,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" 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": version "4.4.9" 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" 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: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" diff --git a/packages/pl-fe/src/actions/external-auth.ts b/packages/pl-fe/src/actions/external-auth.ts index 550d445c5..14dc76f6d 100644 --- a/packages/pl-fe/src/actions/external-auth.ts +++ b/packages/pl-fe/src/actions/external-auth.ts @@ -47,11 +47,11 @@ const externalAuthorize = (instance: Instance, baseURL: string) => const scopes = getInstanceScopes(instance); return dispatch(createExternalApp(instance, baseURL)).then((app) => { - const { client_id, redirect_uri } = app as Record; + const { client_id, redirect_uri } = app; const query = new URLSearchParams({ client_id, - redirect_uri, + redirect_uri: redirect_uri || app.redirect_uris[0]!, response_type: 'code', scope: scopes, }); diff --git a/packages/pl-fe/src/actions/settings.ts b/packages/pl-fe/src/actions/settings.ts index 9d2fbf957..04e6c526b 100644 --- a/packages/pl-fe/src/actions/settings.ts +++ b/packages/pl-fe/src/actions/settings.ts @@ -13,8 +13,6 @@ import type { AppDispatch, RootState } from 'pl-fe/store'; const FE_NAME = 'pl_fe'; -const getAccount = makeGetAccount(); - /** Options when changing/saving settings. */ type SettingOpts = { /** Whether to display an alert when settings are saved. */ @@ -71,7 +69,7 @@ const updateSettingsStore = (settings: any) => }, })); } else { - const accountUrl = getAccount(state, state.me as string)!.url; + const accountUrl = makeGetAccount()(state, state.me as string)!.url; return updateAuthAccount(accountUrl, settings); } diff --git a/packages/pl-fe/src/features/developers/apps/create.tsx b/packages/pl-fe/src/features/developers/apps/create.tsx index 8671a4639..4021ee28b 100644 --- a/packages/pl-fe/src/features/developers/apps/create.tsx +++ b/packages/pl-fe/src/features/developers/apps/create.tsx @@ -16,7 +16,7 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useOwnAccount } from 'pl-fe/hooks/use-own-account'; import { getBaseURL } from 'pl-fe/utils/accounts'; -import type { Token } from 'pl-api'; +import type { CredentialApplication, Token } from 'pl-api'; const messages = defineMessages({ heading: { id: 'column.app_create', defaultMessage: 'Create app' }, @@ -53,7 +53,7 @@ const CreateApp: React.FC = () => { }); }; - const handleCreateToken = (app: Record) => { + const handleCreateToken = (app: CredentialApplication) => { const baseURL = getBaseURL(account!); const tokenParams = { diff --git a/packages/pl-fe/src/reducers/auth.ts b/packages/pl-fe/src/reducers/auth.ts index 7f097df66..48d820206 100644 --- a/packages/pl-fe/src/reducers/auth.ts +++ b/packages/pl-fe/src/reducers/auth.ts @@ -1,6 +1,6 @@ import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; 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 { MASTODON_PRELOAD_IMPORT, type PreloadAction } from 'pl-fe/actions/preload'; @@ -33,7 +33,7 @@ const AuthUserRecord = ImmutableRecord({ }); const ReducerRecord = ImmutableRecord({ - app: null as Application | null, + app: null as CredentialApplication | null, tokens: ImmutableMap(), users: ImmutableMap(), me: null as string | null, From 40c0c7512df80d2a4627fa24cd9f273c7a0366f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 2 Nov 2024 11:20:21 +0100 Subject: [PATCH 2/9] pl-api: Add missing files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../entities/grouped-notifications-results.ts | 143 ++++++++++++++++++ .../lib/params/grouped-notifications.ts | 18 +++ 2 files changed, 161 insertions(+) create mode 100644 packages/pl-api/lib/entities/grouped-notifications-results.ts create mode 100644 packages/pl-api/lib/params/grouped-notifications.ts diff --git a/packages/pl-api/lib/entities/grouped-notifications-results.ts b/packages/pl-api/lib/entities/grouped-notifications-results.ts new file mode 100644 index 000000000..646276c47 --- /dev/null +++ b/packages/pl-api/lib/entities/grouped-notifications-results.ts @@ -0,0 +1,143 @@ +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()), + status_id: v.fallback(v.optional(v.string()), undefined), + + 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 statusNotificationGroupSchema = v.object({ + ...baseNotificationGroupSchema.entries, + type: v.picklist(['status', 'mention', '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> = 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, + statusNotificationGroupSchema, + reportNotificationGroupSchema, + severedRelationshipNotificationGroupSchema, + moderationWarningNotificationGroupSchema, + moveNotificationGroupSchema, + emojiReactionNotificationGroupSchema, + chatMentionNotificationGroupSchema, + eventParticipationRequestNotificationGroupSchema, + ])) as any; + +type NotificationGroup = v.InferOutput< + | typeof accountNotificationGroupSchema + | 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; + +export { notificationGroupSchema, groupedNotificationsResultsSchema, type NotificationGroup, type GroupedNotificationsResults }; diff --git a/packages/pl-api/lib/params/grouped-notifications.ts b/packages/pl-api/lib/params/grouped-notifications.ts new file mode 100644 index 000000000..2c90b8a3d --- /dev/null +++ b/packages/pl-api/lib/params/grouped-notifications.ts @@ -0,0 +1,18 @@ +import type { PaginationParams } from './common'; + +interface GetGroupedNotificationsParams extends PaginationParams { + /** Types to include in the result. */ + types?: Array; + /** Types to exclude from the results. */ + exclude_types?: Array; + /** 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; + /** Whether to include notifications filtered by the user’s NotificationPolicy. Defaults to false. */ + include_filtered?: boolean; +} + +export type { GetGroupedNotificationsParams }; From fe5653cce950debfc80ec5f8bb95de0d40c1a955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 00:18:29 +0100 Subject: [PATCH 3/9] Mostly migrate pl-fe notifications to notification groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-api/lib/client.ts | 100 ++++++++++- .../entities/grouped-notifications-results.ts | 11 +- packages/pl-api/lib/entities/index.ts | 1 + .../lib/params/grouped-notifications.ts | 15 +- packages/pl-api/lib/params/index.ts | 1 + packages/pl-fe/src/actions/notifications.ts | 94 +++++----- .../notifications/components/notification.tsx | 22 +-- .../src/features/notifications/index.tsx | 17 +- .../pl-fe/src/normalizers/notification.ts | 73 ++------ packages/pl-fe/src/reducers/notifications.ts | 161 +++++++++--------- packages/pl-fe/src/selectors/index.ts | 29 ++-- 11 files changed, 301 insertions(+), 223 deletions(-) diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index bcfe98eea..89774f1d2 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -1,4 +1,5 @@ import omit from 'lodash.omit'; +import pick from 'lodash.pick'; import * as v from 'valibot'; import { @@ -110,7 +111,7 @@ import { MuteAccountParams, UpdateFilterParams, } from './params/filtering'; -import { GetGroupedNotificationsParams } from './params/grouped-notifications'; +import { GetGroupedNotificationsParams, GetUnreadNotificationGroupCountParams } from './params/grouped-notifications'; import { CreateGroupParams, GetGroupBlocksParams, @@ -193,6 +194,7 @@ import request, { getNextLink, getPrevLink, type RequestBody, RequestMeta } from import { buildFullPath } from './utils/url'; import type { + Account, AdminAccount, AdminAnnouncement, AdminModerationLogEntry, @@ -299,6 +301,28 @@ class PlApiClient { }; }; + #paginatedSingleGet = async (input: URL | RequestInfo, body: RequestBody, schema: v.BaseSchema>): Promise> => { + 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: { query?: string; filters?: string; @@ -398,11 +422,16 @@ class PlApiClient { } const groupedNotificationsResults: GroupedNotificationsResults = { - accounts: items.map(({ account }) => account), - statuses: items.reduce>((statuses, notification) => { - if ('status' in notification) statuses.push(notification.status); + accounts: Object.values(items.reduce>((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>((statuses, notification) => { + if ('status' in notification) statuses[notification.status.id] = notification.status; return statuses; - }, []), + }, {})), notification_groups: notificationGroups, }; @@ -2767,11 +2796,66 @@ class PlApiClient { }; public readonly groupedNotifications = { - getGroupedNotifications: async (params: GetGroupedNotificationsParams) => { + getGroupedNotifications: async (params: GetGroupedNotificationsParams, meta?: RequestMeta) => { if (this.features.groupedNotifications) { - return this.#paginatedGet('/api/v2/notifications', { params }, groupedNotificationsResultsSchema); + return this.#paginatedSingleGet('/api/v2/notifications', { ...meta, params }, groupedNotificationsResultsSchema); } else { - return this.#groupNotifications(await this.notifications.getNotifications(), params); + 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); + } + }, + + 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; + } + }, + + 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); + } + }, + + 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; + } + }, + + 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']), + ); } }, }; diff --git a/packages/pl-api/lib/entities/grouped-notifications-results.ts b/packages/pl-api/lib/entities/grouped-notifications-results.ts index 646276c47..53eeadece 100644 --- a/packages/pl-api/lib/entities/grouped-notifications-results.ts +++ b/packages/pl-api/lib/entities/grouped-notifications-results.ts @@ -38,9 +38,16 @@ const accountNotificationGroupSchema = v.object({ 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', 'mention', 'reblog', 'favourite', 'poll', 'update', 'event_reminder']), + type: v.picklist(['status', 'reblog', 'favourite', 'poll', 'update', 'event_reminder']), status_id: v.string(), }); @@ -105,6 +112,7 @@ const notificationGroupSchema: v.BaseSchema; + /** Types of notifications that should not count towards unread notifications. */ + exclude_types?: Array; + /** 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; +} + +export type { GetGroupedNotificationsParams, GetUnreadNotificationGroupCountParams }; diff --git a/packages/pl-api/lib/params/index.ts b/packages/pl-api/lib/params/index.ts index dcf70254f..08b7056b0 100644 --- a/packages/pl-api/lib/params/index.ts +++ b/packages/pl-api/lib/params/index.ts @@ -4,6 +4,7 @@ export * from './apps'; export * from './chats'; export * from './events'; export * from './filtering'; +export * from './grouped-notifications'; export * from './groups'; export * from './instance'; export * from './interaction-requests'; diff --git a/packages/pl-fe/src/actions/notifications.ts b/packages/pl-fe/src/actions/notifications.ts index e41c7a091..a1ca22390 100644 --- a/packages/pl-fe/src/actions/notifications.ts +++ b/packages/pl-fe/src/actions/notifications.ts @@ -1,10 +1,11 @@ import IntlMessageFormat from 'intl-messageformat'; + import 'intl-pluralrules'; import { defineMessages } from 'react-intl'; import { getClient } from 'pl-fe/api'; 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 { useSettingsStore } from 'pl-fe/stores/settings'; import { isLoggedIn } from 'pl-fe/utils/auth'; @@ -18,7 +19,7 @@ import { importEntities } from './importer'; import { saveMarker } from './markers'; 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'; const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE' as const; @@ -58,8 +59,8 @@ defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); -const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: Array) => { - const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); +const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: Array) => { + const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.sample_account_ids).flat(); if (accountIds.length > 0) { dispatch(fetchRelationships(accountIds)); @@ -76,13 +77,16 @@ const updateNotifications = (notification: BaseNotification) => statuses: [getNotificationStatus(notification)], })); + if (showInColumn) { + const normalizedNotification = normalizeNotification(notification); + dispatch({ type: NOTIFICATIONS_UPDATE, - notification: normalizeNotification(notification), + notification: normalizedNotification, }); - fetchRelatedRelationships(dispatch, [notification]); + fetchRelatedRelationships(dispatch, [normalizedNotification]); } }; @@ -195,7 +199,7 @@ const expandNotifications = ({ maxId }: Record = {}, done: () => an } } - const params: Record = { + const params: GetGroupedNotificationsParams = { max_id: maxId, }; @@ -203,7 +207,7 @@ const expandNotifications = ({ maxId }: Record = {}, done: () => an if (features.notificationsIncludeTypes) { params.types = NOTIFICATION_TYPES.filter(type => !EXCLUDE_TYPES.includes(type as any)); } else { - params.exclude_types = EXCLUDE_TYPES; + params.exclude_types = [...EXCLUDE_TYPES]; } } else { const filtered = FILTER_TYPES[activeFilter] || [activeFilter]; @@ -215,51 +219,65 @@ const expandNotifications = ({ maxId }: Record = {}, done: () => an } 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()); - 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; statuses: Record }); - + return getClient(state).groupedNotifications.getGroupedNotifications(params, { signal: abortExpandNotifications.signal }).then(({ items: { accounts, statuses, notification_groups }, next }) => { dispatch(importEntities({ - accounts: Object.values(entries.accounts), - statuses: Object.values(entries.statuses), + accounts, + statuses, })); - const deduplicatedNotifications = normalizeNotifications(response.items, state.notifications.items); - - dispatch(expandNotificationsSuccess(deduplicatedNotifications, response.next)); - fetchRelatedRelationships(dispatch, response.items); + dispatch(expandNotificationsSuccess(notification_groups, next)); + fetchRelatedRelationships(dispatch, notification_groups); done(); }).catch(error => { dispatch(expandNotificationsFail(error)); 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; statuses: Record }); + + // 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 expandNotificationsSuccess = (notifications: Array, next: (() => Promise>) | null) => ({ +const expandNotificationsSuccess = (notifications: Array, next: (() => Promise>) | null) => ({ type: NOTIFICATIONS_EXPAND_SUCCESS, notifications, next, @@ -297,7 +315,7 @@ const markReadNotifications = () => if (!isLoggedIn(getState)) return; const state = getState(); - const topNotificationId = state.notifications.items.first()?.id; + const topNotificationId = state.notifications.items.first()?.page_max_id; const lastReadId = state.notifications.lastRead; if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) { diff --git a/packages/pl-fe/src/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 582c77ed1..102045908 100644 --- a/packages/pl-fe/src/features/notifications/components/notification.tsx +++ b/packages/pl-fe/src/features/notifications/components/notification.tsx @@ -24,11 +24,9 @@ import { useModalsStore } from 'pl-fe/stores/modals'; import { useSettingsStore } from 'pl-fe/stores/settings'; 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 { Notification as NotificationEntity } from 'pl-fe/normalizers/notification'; 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 output = [message]; @@ -184,13 +182,13 @@ const avatarSize = 48; interface INotification { hidden?: boolean; - notification: MinifiedNotification; + notification: NotificationGroup; onMoveUp?: (notificationId: string) => void; onMoveDown?: (notificationId: string) => void; onReblog?: (status: StatusEntity, e?: KeyboardEvent) => void; } -const getNotificationStatus = (n: NotificationEntity | BaseNotification) => { +const getNotificationStatus = (n: Pick>, null>, 'type' | 'status'>) => { if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(n.type)) // @ts-ignore return n.status; @@ -207,15 +205,17 @@ const Notification: React.FC = (props) => { const { me } = useLoggedIn(); const { openModal } = useModalsStore(); const { settings } = useSettingsStore(); + const notification = useAppSelector((state) => getNotification(state, props.notification)); + const status = getNotificationStatus(notification); const history = useHistory(); const intl = useIntl(); const instance = useInstance(); const type = notification.type; - const { account, accounts } = notification; - const status = getNotificationStatus(notification); + const { accounts } = notification; + const account = accounts[0]; const getHandlers = () => ({ reply: handleMention, @@ -289,13 +289,13 @@ const Notification: React.FC = (props) => { const handleMoveUp = () => { if (onMoveUp) { - onMoveUp(notification.id); + onMoveUp(notification.group_key); } }; const handleMoveDown = () => { if (onMoveDown) { - onMoveDown(notification.id); + onMoveDown(notification.group_key); } }; @@ -393,7 +393,7 @@ const Notification: React.FC = (props) => { name: account && typeof account === 'object' ? account.acct : '', targetName, }), - notification.created_at, + notification.latest_page_notification_at!, ) ); @@ -433,7 +433,7 @@ const Notification: React.FC = (props) => { truncate data-testid='message' > - + )} diff --git a/packages/pl-fe/src/features/notifications/index.tsx b/packages/pl-fe/src/features/notifications/index.tsx index fedf50ff2..2af3eb865 100644 --- a/packages/pl-fe/src/features/notifications/index.tsx +++ b/packages/pl-fe/src/features/notifications/index.tsx @@ -32,7 +32,7 @@ const messages = defineMessages({ const getNotifications = createSelector([ (state: RootState) => state.notifications.items.toList(), -], (notifications) => notifications.filter(item => item !== null && !item.duplicate)); +], (notifications) => notifications.filter(item => item !== null)); const Notifications = () => { const dispatch = useAppDispatch(); @@ -54,8 +54,13 @@ const Notifications = () => { // }; const handleLoadOlder = useCallback(debounce(() => { - const last = notifications.last(); - dispatch(expandNotifications({ maxId: last && last.id })); + const minId = notifications.reduce( + (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]); const handleScroll = useCallback(debounce((startIndex?: number) => { @@ -63,12 +68,12 @@ const Notifications = () => { }, 100), []); 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); }; 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); }; @@ -111,7 +116,7 @@ const Notifications = () => { } else if (notifications.size > 0 || hasMore) { scrollableContent = notifications.map((item) => ( ({ - ...notification, - duplicate: false, - account: normalizeAccount(notification.account), - account_id: notification.account.id, - accounts: [normalizeAccount(notification.account)], - account_ids: [notification.account.id], +const normalizeNotification = (notification: BaseNotification): NotificationGroup => ({ + ...(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 normalizeNotifications = (notifications: Array, stateNotifications?: ImmutableOrderedMap) => { - 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; - -export { normalizeNotification, normalizeNotifications, type Notification }; +export { normalizeNotification }; diff --git a/packages/pl-fe/src/reducers/notifications.ts b/packages/pl-fe/src/reducers/notifications.ts index 445e406b1..3c4b63792 100644 --- a/packages/pl-fe/src/reducers/notifications.ts +++ b/packages/pl-fe/src/reducers/notifications.ts @@ -1,5 +1,4 @@ import { Record as ImmutableRecord, OrderedMap as ImmutableOrderedMap } from 'immutable'; -import omit from 'lodash/omit'; import { ACCOUNT_BLOCK_SUCCESS, @@ -27,18 +26,17 @@ import { } from '../actions/notifications'; 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 } from 'pl-fe/normalizers/notification'; +import type { Notification as BaseNotification, Markers, NotificationGroup, PaginatedResponse, Relationship } from 'pl-api'; import type { AnyAction } from 'redux'; const QueuedNotificationRecord = ImmutableRecord({ - notification: {} as any as BaseNotification, + notification: {} as any as NotificationGroup, intlMessages: {} as Record, intlLocale: '', }); const ReducerRecord = ImmutableRecord({ - items: ImmutableOrderedMap(), + items: ImmutableOrderedMap(), hasMore: true, top: false, unread: 0, @@ -54,103 +52,103 @@ type QueuedNotification = ReturnType; const parseId = (id: string | number) => parseInt(id as string, 10); // For sorting the notifications -const comparator = (a: Pick, b: Pick) => { - const parse = (m: Pick) => parseId(m.id); +const comparator = (a: Pick, b: Pick) => { + const parse = (m: Pick) => parseId(m.group_key); if (parse(a) < parse(b)) return 1; if (parse(a) > parse(b)) return -1; return 0; }; -const minifyNotification = (notification: Notification) => { - // @ts-ignore - const minifiedNotification: { - duplicate: boolean; - account_id: string; - account_ids: string[]; - created_at: string; - id: string; - } & ( - | { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' } - | { - type: 'mention' | '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']), - created_at: notification.created_at, - id: notification.id, - type: notification.type, - }; +// const minifyNotification = (notification: Notification) => { +// // @ts-ignore +// const minifiedNotification: { +// duplicate: boolean; +// account_id: string; +// account_ids: string[]; +// created_at: string; +// id: string; +// } & ( +// | { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' } +// | { +// type: 'mention' | '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']), +// created_at: notification.latest_page_notification_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; +// // @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; -}; +// return minifiedNotification; +// }; -type MinifiedNotification = ReturnType; +// type MinifiedNotification = ReturnType; // Count how many notifications appear after the given ID (for unread count) -const countFuture = (notifications: ImmutableOrderedMap, lastId: string | number) => +const countFuture = (notifications: ImmutableOrderedMap, lastId: string | number) => notifications.reduce((acc, notification) => { - if (!notification.duplicate && parseId(notification.id) > parseId(lastId)) { + if (parseId(notification.group_key) > parseId(lastId)) { return acc + 1; } else { return acc; } }, 0); -const importNotification = (state: State, notification: Notification) => { +const importNotification = (state: State, notification: NotificationGroup) => { 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 => { if (top && map.size > 40) { 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>) | null) => { - const items = ImmutableOrderedMap(notifications.map(minifyNotification).map(n => [n.id, n])); +const expandNormalizedNotifications = (state: State, notifications: NotificationGroup[], next: (() => Promise>) | null) => { + const items = ImmutableOrderedMap(notifications.map(n => [n.group_key, n])); return state.withMutations(mutable => { 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) => - 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, type?: string) => { - const helper = (list: ImmutableOrderedMap) => list.filterNot(item => item !== null && accountIds.includes(item.account_ids[0]) && (type === undefined || type === item.type)); + const helper = (list: ImmutableOrderedMap) => list.filterNot(item => item !== null && accountIds.includes(item.sample_account_ids[0]) && (type === undefined || type === item.type)); return state.update('items', helper); }; @@ -177,19 +175,19 @@ const deleteByStatus = (state: State, statusId: string) => // @ts-ignore state.update('items', map => map.filterNot(item => item !== null && item.status === statusId)); -const updateNotificationsQueue = (state: State, notification: BaseNotification, intlMessages: Record, intlLocale: string) => { +const updateNotificationsQueue = (state: State, notification: NotificationGroup, intlMessages: Record, intlLocale: string) => { const queuedNotifications = state.queuedNotifications; const listedNotifications = state.items; 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; const newQueuedNotifications = queuedNotifications; return state.withMutations(mutable => { if (totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { - mutable.set('queuedNotifications', newQueuedNotifications.set(notification.id, QueuedNotificationRecord({ + mutable.set('queuedNotifications', newQueuedNotifications.set(notification.group_key, QueuedNotificationRecord({ notification, intlMessages, intlLocale, @@ -259,7 +257,4 @@ const notifications = (state: State = ReducerRecord(), action: AnyAction | Timel } }; -export { - notifications as default, - type MinifiedNotification, -}; +export { notifications as default }; diff --git a/packages/pl-fe/src/selectors/index.ts b/packages/pl-fe/src/selectors/index.ts index 868656944..96a806d6c 100644 --- a/packages/pl-fe/src/selectors/index.ts +++ b/packages/pl-fe/src/selectors/index.ts @@ -13,12 +13,10 @@ import { validId } from 'pl-fe/utils/auth'; import ConfigDB from 'pl-fe/utils/config-db'; 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 { Account } from 'pl-fe/normalizers/account'; 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 { MRFSimple } from 'pl-fe/schemas/pleroma'; 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; const selectAccounts = (state: RootState, accountIds: Array) => - 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) => { if (state.me) { @@ -177,23 +177,16 @@ const makeGetStatus = () => createSelector( type SelectedStatus = Exclude>, null>; const makeGetNotification = () => createSelector([ - (_state: RootState, notification: MinifiedNotification) => notification, + (_state: RootState, notification: NotificationGroup) => notification, // @ts-ignore - (state: RootState, notification: MinifiedNotification) => selectAccount(state, notification.account_id), + (state: RootState, notification: NotificationGroup) => selectAccount(state, notification.target_id), // @ts-ignore - (state: RootState, notification: MinifiedNotification) => selectAccount(state, notification.target_id), - // @ts-ignore - (state: RootState, notification: MinifiedNotification) => state.statuses.get(notification.status_id), - (state: RootState, notification: MinifiedNotification) => notification.account_ids ? selectAccounts(state, notification.account_ids) : null, -], (notification, account, target, status, accounts): MinifiedNotification & Notification => ({ + (state: RootState, notification: NotificationGroup) => state.statuses.get(notification.status_id), + (state: RootState, notification: NotificationGroup) => selectAccounts(state, notification.sample_account_ids), +], (notification, target, status, accounts) => ({ ...notification, - // @ts-ignore - account: account || null, - // @ts-ignore - target: target || null, - // @ts-ignore - status: status || null, - // @ts-ignore + target, + status, accounts, })); From b38eb86ef7a79c6ba755012e16fde28f470fb5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 00:38:20 +0100 Subject: [PATCH 4/9] pl-fe: do not store serialized client in local storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-fe/src/reducers/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pl-fe/src/reducers/auth.ts b/packages/pl-fe/src/reducers/auth.ts index 48d820206..af5af73d4 100644 --- a/packages/pl-fe/src/reducers/auth.ts +++ b/packages/pl-fe/src/reducers/auth.ts @@ -145,7 +145,7 @@ const sanitizeState = (state: State) => { }); }; -const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS())); +const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.delete('client').toJS())); const persistSession = (state: State) => { const me = state.me; From 89f91d6e3c0376f1e9aca4bff8d1f34e14f41a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 15:35:05 +0100 Subject: [PATCH 5/9] pl-fe: Improve types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../lib/entities/grouped-notifications-results.ts | 1 - .../notifications/components/notification.tsx | 4 ++-- packages/pl-fe/src/selectors/index.ts | 15 ++++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/pl-api/lib/entities/grouped-notifications-results.ts b/packages/pl-api/lib/entities/grouped-notifications-results.ts index 53eeadece..96b6483e8 100644 --- a/packages/pl-api/lib/entities/grouped-notifications-results.ts +++ b/packages/pl-api/lib/entities/grouped-notifications-results.ts @@ -27,7 +27,6 @@ const baseNotificationGroupSchema = v.object({ 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()), - status_id: v.fallback(v.optional(v.string()), undefined), is_muted: v.fallback(v.optional(v.boolean()), undefined), is_seen: v.fallback(v.optional(v.boolean()), undefined), diff --git a/packages/pl-fe/src/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 654a25783..3b924aefa 100644 --- a/packages/pl-fe/src/features/notifications/components/notification.tsx +++ b/packages/pl-fe/src/features/notifications/components/notification.tsx @@ -19,7 +19,7 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useInstance } from 'pl-fe/hooks/use-instance'; import { useLoggedIn } from 'pl-fe/hooks/use-logged-in'; -import { makeGetNotification } from 'pl-fe/selectors'; +import { makeGetNotification, SelectedNotification } from 'pl-fe/selectors'; import { useModalsStore } from 'pl-fe/stores/modals'; import { useSettingsStore } from 'pl-fe/stores/settings'; import { NotificationType } from 'pl-fe/utils/notification'; @@ -188,7 +188,7 @@ interface INotification { onReblog?: (status: StatusEntity, e?: KeyboardEvent) => void; } -const getNotificationStatus = (n: Pick>, null>, 'type' | 'status'>) => { +const getNotificationStatus = (n: SelectedNotification) => { if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(n.type)) // @ts-ignore return n.status; diff --git a/packages/pl-fe/src/selectors/index.ts b/packages/pl-fe/src/selectors/index.ts index 96a806d6c..1375c0381 100644 --- a/packages/pl-fe/src/selectors/index.ts +++ b/packages/pl-fe/src/selectors/index.ts @@ -183,13 +183,25 @@ const makeGetNotification = () => createSelector([ // @ts-ignore (state: RootState, notification: NotificationGroup) => state.statuses.get(notification.status_id), (state: RootState, notification: NotificationGroup) => selectAccounts(state, notification.sample_account_ids), -], (notification, target, status, accounts) => ({ +], (notification, target, status, accounts): SelectedNotification => ({ ...notification, target, status, accounts, })); +type SelectedNotification = NotificationGroup & { + accounts: Array; +} & ({ + 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 & { status: MinifiedStatus; account: BaseAccount; @@ -350,6 +362,7 @@ export { makeGetStatus, type SelectedStatus, makeGetNotification, + type SelectedNotification, type AccountGalleryAttachment, getAccountGallery, getGroupGallery, From d355651813dbfebd1a3a2401824aa8d05fa4781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 20:02:01 +0100 Subject: [PATCH 6/9] pl-fe: Fix types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-fe/src/actions/notifications.ts | 1 - .../src/features/notifications/components/notification.tsx | 4 ++-- packages/pl-fe/src/reducers/notifications.ts | 4 ++-- packages/pl-fe/src/selectors/index.ts | 2 ++ 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/pl-fe/src/actions/notifications.ts b/packages/pl-fe/src/actions/notifications.ts index a1ca22390..7731a91f8 100644 --- a/packages/pl-fe/src/actions/notifications.ts +++ b/packages/pl-fe/src/actions/notifications.ts @@ -1,5 +1,4 @@ import IntlMessageFormat from 'intl-messageformat'; - import 'intl-pluralrules'; import { defineMessages } from 'react-intl'; diff --git a/packages/pl-fe/src/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 3b924aefa..359457574 100644 --- a/packages/pl-fe/src/features/notifications/components/notification.tsx +++ b/packages/pl-fe/src/features/notifications/components/notification.tsx @@ -19,7 +19,7 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useInstance } from 'pl-fe/hooks/use-instance'; import { useLoggedIn } from 'pl-fe/hooks/use-logged-in'; -import { makeGetNotification, SelectedNotification } from 'pl-fe/selectors'; +import { makeGetNotification } from 'pl-fe/selectors'; import { useModalsStore } from 'pl-fe/stores/modals'; import { useSettingsStore } from 'pl-fe/stores/settings'; import { NotificationType } from 'pl-fe/utils/notification'; @@ -188,7 +188,7 @@ interface INotification { onReblog?: (status: StatusEntity, e?: KeyboardEvent) => void; } -const getNotificationStatus = (n: SelectedNotification) => { +const getNotificationStatus = (n: Pick & ({ status: StatusEntity } | { })) => { if (['mention', 'status', 'reblog', 'favourite', 'poll', 'update', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request'].includes(n.type)) // @ts-ignore return n.status; diff --git a/packages/pl-fe/src/reducers/notifications.ts b/packages/pl-fe/src/reducers/notifications.ts index 3c4b63792..799185c98 100644 --- a/packages/pl-fe/src/reducers/notifications.ts +++ b/packages/pl-fe/src/reducers/notifications.ts @@ -30,7 +30,7 @@ import type { Notification as BaseNotification, Markers, NotificationGroup, Pagi import type { AnyAction } from 'redux'; const QueuedNotificationRecord = ImmutableRecord({ - notification: {} as any as NotificationGroup, + notification: {} as any as BaseNotification, intlMessages: {} as Record, intlLocale: '', }); @@ -175,7 +175,7 @@ const deleteByStatus = (state: State, statusId: string) => // @ts-ignore state.update('items', map => map.filterNot(item => item !== null && item.status === statusId)); -const updateNotificationsQueue = (state: State, notification: NotificationGroup, intlMessages: Record, intlLocale: string) => { +const updateNotificationsQueue = (state: State, notification: BaseNotification, intlMessages: Record, intlLocale: string) => { const queuedNotifications = state.queuedNotifications; const listedNotifications = state.items; const totalQueuedNotificationsCount = state.totalQueuedNotificationsCount; diff --git a/packages/pl-fe/src/selectors/index.ts b/packages/pl-fe/src/selectors/index.ts index 1375c0381..e2acb9ca8 100644 --- a/packages/pl-fe/src/selectors/index.ts +++ b/packages/pl-fe/src/selectors/index.ts @@ -185,7 +185,9 @@ const makeGetNotification = () => createSelector([ (state: RootState, notification: NotificationGroup) => selectAccounts(state, notification.sample_account_ids), ], (notification, target, status, accounts): SelectedNotification => ({ ...notification, + // @ts-ignore target, + // @ts-ignore status, accounts, })); From 1acf505af85fae5260359e59e4696abf39ed1675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 20:18:07 +0100 Subject: [PATCH 7/9] pl-fe: Fix persistAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-fe/src/reducers/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/pl-fe/src/reducers/auth.ts b/packages/pl-fe/src/reducers/auth.ts index af5af73d4..14675226e 100644 --- a/packages/pl-fe/src/reducers/auth.ts +++ b/packages/pl-fe/src/reducers/auth.ts @@ -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 me = state.me; From 255705f3af35310647ff9ddf23127d108ef61c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 20:28:47 +0100 Subject: [PATCH 8/9] pl-api: Update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-api/lib/client.ts | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 89774f1d2..97a81f7ff 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -2795,7 +2795,17 @@ 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); @@ -2808,6 +2818,13 @@ class PlApiClient { } }, + /** + * 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}`); @@ -2825,6 +2842,13 @@ class PlApiClient { } }, + /** + * 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' }); @@ -2835,6 +2859,12 @@ class PlApiClient { } }, + /** + * 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`); @@ -2845,6 +2875,13 @@ class PlApiClient { } }, + /** + * 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 }); From 1a03b0ea7b948d0d6397a03bdb3433a4ec9eece4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 3 Nov 2024 20:30:11 +0100 Subject: [PATCH 9/9] pl-api: release 0.1.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- packages/pl-api/package.json | 2 +- packages/pl-fe/package.json | 2 +- packages/pl-fe/yarn.lock | 14 ++++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/pl-api/package.json b/packages/pl-api/package.json index 6829a0e21..a15d22de1 100644 --- a/packages/pl-api/package.json +++ b/packages/pl-api/package.json @@ -1,6 +1,6 @@ { "name": "pl-api", - "version": "0.1.6", + "version": "0.1.7", "type": "module", "homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api", "repository": { diff --git a/packages/pl-fe/package.json b/packages/pl-fe/package.json index 249a737fd..1f3a73681 100644 --- a/packages/pl-fe/package.json +++ b/packages/pl-fe/package.json @@ -102,7 +102,7 @@ "mini-css-extract-plugin": "^2.9.1", "multiselect-react-dropdown": "^2.0.25", "path-browserify": "^1.0.1", - "pl-api": "^0.1.5", + "pl-api": "^0.1.7", "postcss": "^8.4.47", "process": "^0.11.10", "punycode": "^2.1.1", diff --git a/packages/pl-fe/yarn.lock b/packages/pl-fe/yarn.lock index 47d77ceb7..327912e12 100644 --- a/packages/pl-fe/yarn.lock +++ b/packages/pl-fe/yarn.lock @@ -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" 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: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" @@ -7570,13 +7575,14 @@ pkg-dir@^4.1.0: dependencies: find-up "^4.0.0" -pl-api@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.1.5.tgz#373d58fc40ae23b12c6d20def8d3332112e2dd93" - integrity sha512-IMcwANPtTRMv+tTLW4ic/o/usYIHeapR+aOx5YhmmVQsXTiVUZQtCVLmrfIrqMnyXm8PHvE/arzzozRFU8+GXQ== +pl-api@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.1.7.tgz#929b557903b280bda9e7c7bf6aa8cd015698c42f" + integrity sha512-cTUHacufHDlNl7zHsOH+LW5MvotY5pUnBhfFMofVfvGOswURl0F1x1L8+V5k1D4VGn+mE7CnOv3esz/3QrrQmg== dependencies: blurhash "^2.0.5" http-link-header "^1.1.3" + lodash.omit "^4.5.0" lodash.pick "^4.4.0" object-to-formdata "^4.5.1" query-string "^9.1.0"