diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 8039639c4..97a81f7ff 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -1,3 +1,5 @@ +import omit from 'lodash.omit'; +import pick from 'lodash.pick'; import * as v from 'valibot'; import { @@ -27,6 +29,7 @@ import { contextSchema, conversationSchema, credentialAccountSchema, + credentialApplicationSchema, customEmojiSchema, domainBlockSchema, emojiReactionSchema, @@ -70,6 +73,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 +111,7 @@ import { MuteAccountParams, UpdateFilterParams, } from './params/filtering'; +import { GetGroupedNotificationsParams, GetUnreadNotificationGroupCountParams } from './params/grouped-notifications'; import { CreateGroupParams, GetGroupBlocksParams, @@ -189,12 +194,14 @@ import request, { getNextLink, getPrevLink, type RequestBody, RequestMeta } from import { buildFullPath } from './utils/url'; import type { + Account, AdminAccount, AdminAnnouncement, AdminModerationLogEntry, AdminReport, GroupRole, Instance, + Notification, PleromaConfig, Status, StreamingEvent, @@ -228,7 +235,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 @@ -292,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; @@ -353,6 +384,65 @@ 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: 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, + }; + + 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 +453,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 +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 = { /** * 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/entities/grouped-notifications-results.ts b/packages/pl-api/lib/entities/grouped-notifications-results.ts new file mode 100644 index 000000000..96b6483e8 --- /dev/null +++ b/packages/pl-api/lib/entities/grouped-notifications-results.ts @@ -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> = 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; + +export { notificationGroupSchema, groupedNotificationsResultsSchema, type NotificationGroup, type GroupedNotificationsResults }; diff --git a/packages/pl-api/lib/entities/index.ts b/packages/pl-api/lib/entities/index.ts index 1411ead7f..5fb6d6339 100644 --- a/packages/pl-api/lib/entities/index.ts +++ b/packages/pl-api/lib/entities/index.ts @@ -41,6 +41,7 @@ export * from './filter'; export * from './group'; export * from './group-member'; export * from './group-relationship'; +export * from './grouped-notifications-results'; export * from './instance'; export * from './interaction-policy'; export * from './interaction-request'; 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/params/grouped-notifications.ts b/packages/pl-api/lib/params/grouped-notifications.ts new file mode 100644 index 000000000..165b98f0d --- /dev/null +++ b/packages/pl-api/lib/params/grouped-notifications.ts @@ -0,0 +1,31 @@ +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; +} + +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; + /** 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-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..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": { @@ -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/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/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/notifications.ts b/packages/pl-fe/src/actions/notifications.ts index e41c7a091..7731a91f8 100644 --- a/packages/pl-fe/src/actions/notifications.ts +++ b/packages/pl-fe/src/actions/notifications.ts @@ -4,7 +4,7 @@ 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 +18,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 +58,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 +76,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 +198,7 @@ const expandNotifications = ({ maxId }: Record = {}, done: () => an } } - const params: Record = { + const params: GetGroupedNotificationsParams = { max_id: maxId, }; @@ -203,7 +206,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 +218,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 +314,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/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/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 650175968..359457574 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 & ({ 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; @@ -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/auth.ts b/packages/pl-fe/src/reducers/auth.ts index 8f8467cb0..14675226e 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, @@ -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; diff --git a/packages/pl-fe/src/reducers/notifications.ts b/packages/pl-fe/src/reducers/notifications.ts index 445e406b1..799185c98 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,8 +26,7 @@ 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({ @@ -38,7 +36,7 @@ const QueuedNotificationRecord = ImmutableRecord({ }); 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); }; @@ -182,14 +180,14 @@ const updateNotificationsQueue = (state: State, notification: BaseNotification, 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..e2acb9ca8 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,26 +177,33 @@ 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): SelectedNotification => ({ ...notification, // @ts-ignore - account: account || null, - // @ts-ignore - target: target || null, - // @ts-ignore - status: status || null, + target, // @ts-ignore + 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; @@ -357,6 +364,7 @@ export { makeGetStatus, type SelectedStatus, makeGetNotification, + type SelectedNotification, type AccountGalleryAttachment, getAccountGallery, getGroupGallery, 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"