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] 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,