From 103a130f201bb299981fdd66be3b96d1292c2caf Mon Sep 17 00:00:00 2001 From: Ryo Igarashi Date: Sun, 13 Oct 2024 14:25:16 +0900 Subject: [PATCH] feat: Support Mastodon 4.3.0 wip Bump docker image for testing Add support notification group Move NotificationPolicy to v2 Add NotificationsMerged event Add link timeline Add test todo Fix tests fix settings --- cspell.json | 7 +- docker-compose.yaml | 2 +- package-lock.json | 7 + package.json | 1 + src/mastodon/entities/v1/account-warning.ts | 31 +++ src/mastodon/entities/v1/appeal.ts | 11 + .../entities/v1/grouped-notifications.ts | 130 +++++++++ src/mastodon/entities/v1/index.ts | 3 + .../entities/v1/notification-request.ts | 17 ++ src/mastodon/entities/v1/notification.ts | 24 +- .../v1/relationship-severance-event.ts | 22 ++ src/mastodon/entities/v1/rule.ts | 4 + src/mastodon/entities/v1/suggestion.ts | 32 ++- src/mastodon/entities/v2/index.ts | 1 + src/mastodon/entities/v2/instance.ts | 24 ++ .../entities/v2/notification-policy.ts | 24 ++ src/mastodon/rest/client.ts | 1 + src/mastodon/rest/v1/account-repository.ts | 9 + .../rest/v1/notification-repository.ts | 88 +++++- src/mastodon/rest/v1/status-repository.ts | 10 + src/mastodon/rest/v1/timeline-repository.ts | 17 ++ src/mastodon/rest/v2/index.ts | 1 + .../rest/v2/notification-repository.ts | 139 ++++++++++ src/mastodon/streaming/event.ts | 9 +- test-utils/async-next-tick.ts | 5 + tests/rest/v1/conversations.spec.ts | 61 +++-- tests/rest/v1/notifications.spec.ts | 18 +- tests/rest/v1/timelines.spec.ts | 2 + tests/rest/v2/notifications.spec.ts | 251 ++++++++++++++++++ tests/streaming/events.spec.ts | 45 ++-- 30 files changed, 939 insertions(+), 57 deletions(-) create mode 100644 src/mastodon/entities/v1/account-warning.ts create mode 100644 src/mastodon/entities/v1/appeal.ts create mode 100644 src/mastodon/entities/v1/grouped-notifications.ts create mode 100644 src/mastodon/entities/v1/notification-request.ts create mode 100644 src/mastodon/entities/v1/relationship-severance-event.ts create mode 100644 src/mastodon/entities/v2/notification-policy.ts create mode 100644 src/mastodon/rest/v2/notification-repository.ts create mode 100644 test-utils/async-next-tick.ts create mode 100644 tests/rest/v2/notifications.spec.ts diff --git a/cspell.json b/cspell.json index 9edfcad63..3fe129faa 100644 --- a/cspell.json +++ b/cspell.json @@ -1,7 +1,10 @@ { "version": "0.2", "language": "en,en-GB", - "ignorePaths": ["**/node_modules/**", "**/dist/**"], + "ignorePaths": [ + "**/node_modules/**", + "**/dist/**" + ], "words": [ "AGPL", "asynckit", @@ -32,10 +35,12 @@ "reblog", "reblogged", "reblogs", + "sadams", "serializers", "shortcode", "subprotocol", "subresource", + "timeframe", "tootctl", "trendable", "typedoc", diff --git a/docker-compose.yaml b/docker-compose.yaml index b99402911..3ca2edf12 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,7 +23,7 @@ services: mastodon: restart: always - image: neetshin/mastodon-dev:4.2.13 + image: neetshin/mastodon-dev:4.3.0 ports: - "3000:3000" - "4000:4000" diff --git a/package-lock.json b/package-lock.json index f318a1627..4a630f2c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-typescript": "^11.1.6", + "@sadams/wait-for-expect": "^1.1.0", "@size-limit/preset-small-lib": "^11.1.2", "@types/jest": "^29.5.12", "@types/node": "^20.12.8", @@ -2493,6 +2494,12 @@ "win32" ] }, + "node_modules/@sadams/wait-for-expect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sadams/wait-for-expect/-/wait-for-expect-1.1.0.tgz", + "integrity": "sha512-3pp51Hdo0/7VM3hrvSv4Q8YsAv1y4NZdnxocJytFqvIY1yFg641jx1wOFmD8x6F+P7EVWwEQCcF4Bdvg5H9BAw==", + "dev": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "dev": true, diff --git a/package.json b/package.json index a71e9cee1..1090b92f2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-typescript": "^11.1.6", + "@sadams/wait-for-expect": "^1.1.0", "@size-limit/preset-small-lib": "^11.1.2", "@types/jest": "^29.5.12", "@types/node": "^20.12.8", diff --git a/src/mastodon/entities/v1/account-warning.ts b/src/mastodon/entities/v1/account-warning.ts new file mode 100644 index 000000000..15dbe8d1f --- /dev/null +++ b/src/mastodon/entities/v1/account-warning.ts @@ -0,0 +1,31 @@ +import { type Account } from "./account"; +import { type Appeal } from "./appeal"; + +/** + * Moderation warning against a particular account. + */ +export interface AccountWarning { + /** The ID of the account warning in the database. */ + id: string; + /** Action taken against the account. */ + action: AccountWarningAction; + /** Message from the moderator to the target account. */ + text: string; + /** List of status IDs that are relevant to the warning. When action is mark_statuses_as_sensitive or delete_statuses, those are the affected statuses. */ + statusIds: string[]; + /** Account against which a moderation decision has been taken. */ + targetAccount: Account; + /** Appeal submitted by the target account, if any. */ + appeal?: Appeal | null; + /** When the event took place. */ + createdAt: string; +} + +export type AccountWarningAction = + | "none" + | "disable" + | "mark_statuses_as_sensitive" + | "delete_statuses" + | "sensitive" + | "silence" + | "suspend"; diff --git a/src/mastodon/entities/v1/appeal.ts b/src/mastodon/entities/v1/appeal.ts new file mode 100644 index 000000000..4bfbbed6b --- /dev/null +++ b/src/mastodon/entities/v1/appeal.ts @@ -0,0 +1,11 @@ +/** + * Appeal against a moderation action. + */ +export interface Appeal { + /** Text of the appeal from the moderated account to the moderators. */ + text: string; + /** State of the appeal. */ + state: AppealState; +} + +export type AppealState = "approved" | "rejected" | "pending"; diff --git a/src/mastodon/entities/v1/grouped-notifications.ts b/src/mastodon/entities/v1/grouped-notifications.ts new file mode 100644 index 000000000..b8238eea4 --- /dev/null +++ b/src/mastodon/entities/v1/grouped-notifications.ts @@ -0,0 +1,130 @@ +import { type Account } from "./account"; +import { type AccountWarning } from "./account-warning"; +import { type RelationshipSeveranceEvent } from "./relationship-severance-event"; +import { type Report } from "./report"; +import { type Status } from "./status"; + +export interface GroupedNotificationsResults { + /** Accounts referenced by grouped notifications. */ + accounts: Account[]; + /** Partial accounts referenced by grouped notifications. Those are only returned when requesting grouped notifications with `expand_accounts=partial_avatars`. */ + partialAccounts?: PartialAccountWithAvatar[]; + /** Statuses referenced by grouped notifications. */ + statuses: Status[]; + /** The grouped notifications themselves. */ + notificationGroups: NotificationGroup[]; +} + +/** These are stripped-down versions of {@link Account} that only contain what is necessary to display a list of avatars, as well as a few other useful properties. The aim is to cut back on expensive server-side serialization and reduce the network payload size of notification groups. */ +export type PartialAccountWithAvatar = Pick< + Account, + "id" | "acct" | "url" | "avatar" | "avatarStatic" | "locked" | "bot" +>; + +interface BaseNotificationGroup { + /** Group key identifying the grouped notifications. Should be treated as an opaque value. */ + groupKey: string; + /** Total number of individual notifications that are part of this notification group. */ + notificationsCount: number; + /** The type of event that resulted in the notifications in this group. */ + type: T; + /** ID of the most recent notification in the group. */ + mostRecentNotificationId: string; + /** ID of the oldest notification from this group represented within the current page. This is only returned when paginating through notification groups. Useful when polling new notifications. */ + pageMinId?: string; + /** ID of the newest notification from this group represented within the current page. This is only returned when paginating through notification groups. Useful when polling new notifications. */ + pageMaxId?: string; + /** Date at which the most recent notification from this group within the current page has been created. This is only returned when paginating through notification groups. */ + latestPageNotificationAt?: string; + /** IDs of some of the accounts who most recently triggered notifications in this group. */ + sampleAccountIds: string; + /** ID of the Status that was the object of the notification. Attached when type of the notification is favourite, reblog, status, mention, poll, or update. */ + statusId?: undefined | null; + /** Report that was the object of the notification. Attached when type of the notification is admin.report. */ + report?: undefined | null; + /** Summary of the event that caused follow relationships to be severed. Attached when type of the notification is severed_relationships. */ + event?: undefined | null; + /** Moderation warning that caused the notification. Attached when type of the notification is moderation_warning. */ + moderationWarning?: undefined | null; +} + +type NotificationGroupPlain = BaseNotificationGroup; + +type NotificationGroupWithStatusId = BaseNotificationGroup & { + /** ID of the Status that was the object of the notification. Attached when type of the notification is favourite, reblog, status, mention, poll, or update. */ + statusId: string; +}; + +type NotificationGroupWithReport = BaseNotificationGroup & { + /** Report that was the object of the notification. Attached when type of the notification is admin.report. */ + report: Report; +}; + +type NotificationGroupWithEvent = BaseNotificationGroup & { + /** Summary of the event that caused follow relationships to be severed. Attached when type of the notification is severed_relationships. */ + event: RelationshipSeveranceEvent; +}; + +type NotificationGroupWithModerationWarning = BaseNotificationGroup & { + /** Moderation warning that caused the notification. Attached when type of the notification is moderation_warning. */ + moderationWarning: AccountWarning; +}; + +/** Someone mentioned you in their status */ +export type MentionNotificationGroup = NotificationGroupWithStatusId<"mention">; + +/** Someone you enabled notifications for has posted a status */ +export type StatusNotificationGroup = NotificationGroupWithStatusId<"status">; + +/** Someone boosted one of your statuses */ +export type ReblogNotificationGroup = NotificationGroupWithStatusId<"reblog">; + +/** Someone followed you */ +export type FollowNotificationGroup = NotificationGroupPlain<"follow">; + +/** Someone requested to follow you */ +export type FollowRequestNotificationGroup = + NotificationGroupPlain<"follow_request">; + +/** Someone favourited one of your statuses */ +export type FavouriteNotificationGroup = + NotificationGroupWithStatusId<"favourite">; + +/** A poll you have voted in or created has ended */ +export type PollNotificationGroup = NotificationGroupWithStatusId<"poll">; + +/** A status you interacted with has been edited */ +export type UpdateNotificationGroup = NotificationGroupWithStatusId<"update">; + +/** Someone signed up (optionally sent to admins) */ +export type AdminSignUpNotificationGroup = + NotificationGroupPlain<"admin.sign_up">; + +/** A new report has been filed */ +export type AdminReportNotificationGroup = + NotificationGroupWithReport<"admin.report">; + +/** Some of your follow relationships have been severed as a result of a moderation or block event */ +export type SeveredRelationshipsNotificationGroup = + NotificationGroupWithEvent<"severed_relationships">; + +/** A moderator has taken action against your account or has sent you a warning */ +export type ModerationWarningNotificationGroup = + NotificationGroupWithModerationWarning<"moderation_warning">; + +/** Group key identifying the grouped notifications. Should be treated as an opaque value. */ +export type NotificationGroup = + | MentionNotificationGroup + | StatusNotificationGroup + | ReblogNotificationGroup + | FollowNotificationGroup + | FollowRequestNotificationGroup + | FavouriteNotificationGroup + | PollNotificationGroup + | UpdateNotificationGroup + | AdminSignUpNotificationGroup + | AdminReportNotificationGroup + | SeveredRelationshipsNotificationGroup + | ModerationWarningNotificationGroup; + +export type NotificationGroupType = NotificationGroup["type"]; diff --git a/src/mastodon/entities/v1/index.ts b/src/mastodon/entities/v1/index.ts index c514bf359..1a6003368 100644 --- a/src/mastodon/entities/v1/index.ts +++ b/src/mastodon/entities/v1/index.ts @@ -14,17 +14,20 @@ export * from "./filter"; export * from "./filter-keyword"; export * from "./filter-result"; export * from "./filter-status"; +export * from "./grouped-notifications"; export * from "./identity-proof"; export * from "./instance"; export * from "./list"; export * from "./marker"; export * from "./media-attachment"; export * from "./notification"; +export * from "./notification-request"; export * from "./poll"; export * from "./preference"; export * from "./preview-card"; export * from "./reaction"; export * from "./relationship"; +export * from "./relationship-severance-event"; export * from "./report"; export * from "./role"; export * from "./rule"; diff --git a/src/mastodon/entities/v1/notification-request.ts b/src/mastodon/entities/v1/notification-request.ts new file mode 100644 index 000000000..3012ef3f0 --- /dev/null +++ b/src/mastodon/entities/v1/notification-request.ts @@ -0,0 +1,17 @@ +import { type Account } from "./account"; +import { type Status } from "./status"; + +export interface NotificationRequest { + /** The id of the notification request in the database. */ + id: string; + /** The timestamp of the notification request, i.e. when the first filtered notification from that user was created. */ + createdAt: string; + /** The timestamp of when the notification request was last updated. */ + updatedAt: string; + /** The account that performed the action that generated the filtered notifications. */ + account: Account; + /** How many of this account’s notifications were filtered. */ + notificationsCount: number; + /** Most recent status associated with a filtered notification from that account. */ + lastStatus?: Status | null; +} diff --git a/src/mastodon/entities/v1/notification.ts b/src/mastodon/entities/v1/notification.ts index 589c4f672..bf4f3a6ed 100644 --- a/src/mastodon/entities/v1/notification.ts +++ b/src/mastodon/entities/v1/notification.ts @@ -1,4 +1,6 @@ import { type Account } from "./account"; +import { type AccountWarning } from "./account-warning"; +import { type RelationshipSeveranceEvent } from "./relationship-severance-event"; import { type Report } from "./report"; import { type Status } from "./status"; @@ -11,6 +13,8 @@ interface BaseNotification { createdAt: string; /** The account that performed the action that generated the notification. */ account: Account; + /** Group key shared by similar notifications, to be used in the grouped notifications feature. Should be considered opaque, but ungrouped notifications can be assumed to have a group_key of the form ungrouped-{notification_id} */ + groupKey: string; } type BaseNotificationPlain = BaseNotification & { @@ -82,6 +86,22 @@ export type AdminSignUpNotification = BaseNotificationPlain<"admin.sign_up">; export type AdminReportNotification = BaseNotificationWithReport<"admin.report">; +/** + * Some of your follow relationships have been severed as a result of a moderation or block event + */ +export type SeveredRelationshipsNotification = + BaseNotificationPlain<"severed_relationships"> & { + relationshipSeveranceEvent: RelationshipSeveranceEvent; + }; + +/** + * A moderator has taken action against your account or has sent you a warning + */ +export type ModerationWarningNotification = + BaseNotificationPlain<"moderation_warning"> & { + moderationWarning: AccountWarning; + }; + /** * Represents a notification of an event relevant to the user. * @see https://docs.joinmastodon.org/entities/notification @@ -96,6 +116,8 @@ export type Notification = | PollNotification | UpdateNotification | AdminSignUpNotification - | AdminReportNotification; + | AdminReportNotification + | SeveredRelationshipsNotification + | ModerationWarningNotification; export type NotificationType = Notification["type"]; diff --git a/src/mastodon/entities/v1/relationship-severance-event.ts b/src/mastodon/entities/v1/relationship-severance-event.ts new file mode 100644 index 000000000..7ecd608f9 --- /dev/null +++ b/src/mastodon/entities/v1/relationship-severance-event.ts @@ -0,0 +1,22 @@ +/** + * Summary of a moderation or block event that caused follow relationships to be severed. + */ +export interface RelationshipSeveranceEvent { + /** The ID of the relationship severance event in the database. */ + id: string; + /** Type of event. */ + type: RelationshipSeveranceEventType; + /** Whether the list of severed relationships is unavailable because the underlying issue has been purged. */ + purged: boolean; + /** Name of the target of the moderation/block event. This is either a domain name or a user handle, depending on the event type. */ + targetName: string; + /** Number of follow relationships (in either direction) that were severed. */ + relationshipsCount?: number | null; + /** When the event took place. */ + createdAt: string; +} + +export type RelationshipSeveranceEventType = + | "domain_block" + | "user_domain_block" + | "account_suspension"; diff --git a/src/mastodon/entities/v1/rule.ts b/src/mastodon/entities/v1/rule.ts index 9fd602b44..886ef97a0 100644 --- a/src/mastodon/entities/v1/rule.ts +++ b/src/mastodon/entities/v1/rule.ts @@ -1,4 +1,8 @@ export interface Rule { + /** An identifier for the rule. */ id: string; + /** The rule to be followed. */ text: string; + /** Longer-form description of the rule. */ + hint: string; } diff --git a/src/mastodon/entities/v1/suggestion.ts b/src/mastodon/entities/v1/suggestion.ts index eb594476c..99f902ac6 100644 --- a/src/mastodon/entities/v1/suggestion.ts +++ b/src/mastodon/entities/v1/suggestion.ts @@ -1,7 +1,32 @@ import { type Account } from "./account"; +/** + * `staff` = This account was manually recommended by your administration team + * `past_interactions` = You have interacted with this account previously + * `global` = This account has many reblogs, favourites, and active local followers within the last 30 days + * + * @deprecated Use {@link SuggestionSource_} instead + */ export type SuggestionSource = "staff" | "past_interactions" | "global"; +/** + * `featured` = This account was manually recommended by your administration team. Equivalent to the staff value for source + * + * `most_followed` = This account has many active local followers + * + * `most_interactions` = This account had many reblogs and favourites within the last 30 days + * + * `similar_to_recently_followed` = This account’s profile is similar to your most recent follows + * + * `friends_of_friends` = This account is followed by people you follow + */ +export type SuggestionSource_ = + | "featured" + | "most_followed" + | "most_interactions" + | "similar_to_recently_followed" + | "friends_of_friends"; + /** * Represents a suggested account to follow and an associated reason for the suggestion. * @see https://docs.joinmastodon.org/entities/Suggestion/ @@ -9,12 +34,13 @@ export type SuggestionSource = "staff" | "past_interactions" | "global"; export interface Suggestion { /** * The reason this account is being suggested. - * `staff` = This account was manually recommended by your administration team - * `past_interactions` = You have interacted with this account previously - * `global` = This account has many reblogs, favourites, and active local followers within the last 30 days + * @deprecated */ source: SuggestionSource; + /** A list of reasons this account is being suggested. This replaces source */ + sources: SuggestionSource_[]; + /** * The account being recommended to follow. */ diff --git a/src/mastodon/entities/v2/index.ts b/src/mastodon/entities/v2/index.ts index 77b56e1f3..feacfb56f 100644 --- a/src/mastodon/entities/v2/index.ts +++ b/src/mastodon/entities/v2/index.ts @@ -1,3 +1,4 @@ export * from "./filter"; export * from "./instance"; +export * from "./notification-policy"; export * from "./search"; diff --git a/src/mastodon/entities/v2/instance.ts b/src/mastodon/entities/v2/instance.ts index bcea4d33b..df211715d 100644 --- a/src/mastodon/entities/v2/instance.ts +++ b/src/mastodon/entities/v2/instance.ts @@ -36,6 +36,8 @@ export interface InstanceUrls { export interface InstanceAccountsConfiguration { /** The maximum number of featured tags allowed for each account. */ maxFeaturedTags: number; + /** The maximum number of pinned statuses for each account. */ + maxPinnedStatuses: number; } export interface InstanceStatusesConfiguration { @@ -78,6 +80,11 @@ export interface InstanceTranslationConfiguration { enabled: boolean; } +export interface InstanceVapidConfiguration { + /** The instances VAPID public key, used for push notifications, the same as */ + publicKey: string; +} + export interface InstanceConfiguration { /** URLs of interest for clients apps. */ urls: InstanceUrls; @@ -91,6 +98,7 @@ export interface InstanceConfiguration { polls: InstancePollsConfiguration; /** Hints related to translation. */ translation: InstanceTranslationConfiguration; + vapid: InstanceVapidConfiguration; } export interface InstanceRegistrations { @@ -109,6 +117,18 @@ export interface InstanceContact { account: Account; } +export interface InstanceApiVersions { + /** API version number that this server implements. Starting from Mastodon v4.3.0, API changes will come with a version number, which clients can check against this value. */ + mastodon: string; +} + +export interface InstanceIcon { + /** The URL of this icon. */ + src: string; + /** The size of this icon. */ + size: string; +} + /** * Represents the software instance of Mastodon running on this domain. * @see https://docs.joinmastodon.org/entities/Instance/ @@ -128,12 +148,16 @@ export interface Instance { usage: InstanceUsage; /** An image used to represent this instance */ thumbnail: InstanceThumbnail; + /** The list of available size variants for this instance configured icon. */ + icon: InstanceIcon[]; /** Primary languages of the website and its staff. */ languages: string[]; /** Configured values and limits for this website. */ configuration: InstanceConfiguration; /** Information about registering for this website. */ registrations: InstanceRegistrations; + /** Information about which version of the API is implemented by this server. It contains at least a mastodon attribute, and other implementations may have their own additional attributes. */ + apiVersions: InstanceApiVersions; /** Hints related to contacting a representative of the website. */ contact: InstanceContact; /** An itemized list of rules for this website. */ diff --git a/src/mastodon/entities/v2/notification-policy.ts b/src/mastodon/entities/v2/notification-policy.ts new file mode 100644 index 000000000..0bddf9d0f --- /dev/null +++ b/src/mastodon/entities/v2/notification-policy.ts @@ -0,0 +1,24 @@ +export type NotificationPolicyType = "accept" | "filter" | "drop"; + +/** + * Represents the notification filtering policy of the user. + */ +export interface NotificationPolicy { + /** Whether to filter notifications from accounts the user is not following. */ + forNotFollowing: NotificationPolicyType; + /** Whether to filter notifications from accounts that are not following the user. */ + forNotFollowers: NotificationPolicyType; + /** Whether to filter notifications from accounts created in the past 30 days. */ + forNewAccounts: NotificationPolicyType; + /** Whether to filter notifications from private mentions. Replies to private mentions initiated by the user, as well as accounts the user follows, are never filtered. */ + forPrivateMentions: NotificationPolicyType; + /** Whether to accept, filter or drop notifications from accounts that were limited by a moderator. drop will prevent creation of the notification object altogether (without preventing the underlying activity), filter will cause it to be marked as filtered, and accept will not affect its processing. Type: String (one of accept, filter or drop) */ + forLimitedAccounts: NotificationPolicyType; + /** Summary of the filtered notifications */ + summary: { + /** Number of different accounts from which the user has non-dismissed filtered notifications. Capped at 100. Type: Integer */ + pendingRequestsCount: number; + /** Number of total non-dismissed filtered notifications. May be inaccurate. */ + pendingNotificationsCount: number; + }; +} diff --git a/src/mastodon/rest/client.ts b/src/mastodon/rest/client.ts index 8c8b1a0d5..de76d20c3 100644 --- a/src/mastodon/rest/client.ts +++ b/src/mastodon/rest/client.ts @@ -43,6 +43,7 @@ export interface Client { readonly filters: v2.FilterRepository; readonly instance: v2.InstanceRepository; readonly media: v2.MediaAttachmentRepository; + readonly notifications: v2.NotificationRepository; readonly suggestions: v2.SuggestionRepository; readonly search: v2.SearchRepository; }; diff --git a/src/mastodon/rest/v1/account-repository.ts b/src/mastodon/rest/v1/account-repository.ts index 2fd2e9710..d9ce7afc3 100644 --- a/src/mastodon/rest/v1/account-repository.ts +++ b/src/mastodon/rest/v1/account-repository.ts @@ -15,6 +15,11 @@ import { import { type Paginator } from "../../paginator"; import { type DefaultPaginationParams } from "../../repository"; +export interface FetchAccountsParams { + /** The IDs of the Accounts in the database. */ + readonly id: readonly string[]; +} + export interface CreateAccountParams { /** The desired username for the account */ readonly username: string; @@ -112,9 +117,13 @@ export interface LookupAccountParams { export interface FetchRelationshipsParams { /** Array of account IDs to check */ readonly id: readonly string[]; + /** Whether relationships should be returned for suspended users, defaults to false. */ + readonly withSuspended?: boolean | null; } export interface AccountRepository { + fetch(params: FetchAccountsParams, meta?: HttpMetaParams): Promise; + $select(id: string): { /** * View information about a profile. diff --git a/src/mastodon/rest/v1/notification-repository.ts b/src/mastodon/rest/v1/notification-repository.ts index 2d8ef3270..330fe7ff7 100644 --- a/src/mastodon/rest/v1/notification-repository.ts +++ b/src/mastodon/rest/v1/notification-repository.ts @@ -1,5 +1,9 @@ import { type HttpMetaParams } from "../../../interfaces"; -import { type Notification, type NotificationType } from "../../entities/v1"; +import { + type Notification, + type NotificationRequest, + type NotificationType, +} from "../../entities/v1"; import { type Paginator } from "../../paginator"; import { type DefaultPaginationParams } from "../../repository"; @@ -12,6 +16,17 @@ export interface ListNotificationsParams extends DefaultPaginationParams { readonly excludeTypes?: readonly NotificationType[] | null; } +export interface FetchUnreadCountParams { + /** Maximum number of results to return. Defaults to 100 notifications. Max 1000 notifications. */ + limit?: number | null; + /** Types of notifications that should count towards unread notifications. */ + types?: readonly NotificationType[] | null; + /** Types of notifications that should not count towards unread notifications. */ + excludeTypes?: readonly NotificationType[] | null; + /** Only count unread notifications received from the specified account. */ + accountId?: string | null; +} + export interface NotificationRepository { /** * Notifications concerning the user. @@ -26,6 +41,19 @@ export interface NotificationRepository { meta?: HttpMetaParams<"json">, ): Paginator; + /** + * 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. + * @param params Query parameter + * @return Array of Notification + * @see https://docs.joinmastodon.org/methods/notifications/ + */ + fetch( + params?: ListNotificationsParams, + meta?: HttpMetaParams, + ): Promise; + $select(id: string): { /** * View information about a notification with a given ID. @@ -48,4 +76,62 @@ export interface NotificationRepository { * @see https://docs.joinmastodon.org/methods/notifications/ */ clear(meta?: HttpMetaParams): Promise; + + requests: { + /** + * Notification requests for notifications filtered by the user’s policy. This API returns Link headers containing links to the next/previous page. + */ + list( + params?: DefaultPaginationParams, + meta?: HttpMetaParams<"json">, + ): Paginator; + + $select(id: string): { + /** + * View information about a notification request with a given ID. + */ + fetch(meta?: HttpMetaParams): Promise; + + /** + * Accept a notification request, which merges the filtered notifications from that user back into the main notification and accepts any future notification from them. + */ + accept(meta?: HttpMetaParams): Promise; + + /** + * Dismiss a notification request, which hides it and prevent it from contributing to the pending notification requests count. + */ + dismiss(meta?: HttpMetaParams): Promise; + }; + + /** + * Accepts multiple notification requests, which merges the filtered notifications from those users back into the main notifications and accepts any future notification from them. + */ + accept(meta?: HttpMetaParams): Promise; + + /** + * Dismiss multiple notification requests, which hides them and prevent them from contributing to the pending notification requests count. + */ + dismiss(meta?: HttpMetaParams): Promise; + + merged: { + /** + * Check whether accepted notification requests have been merged. Accepting notification requests schedules a background job to merge the filtered notifications back into the normal notification list. When that process has finished, the client should refresh the notifications list at its earliest convenience. This is communicated by the notifications_merged streaming event but can also be polled using this endpoint. + */ + fetch(meta?: HttpMetaParams): Promise<{ merged: boolean }>; + }; + }; + + unreadCount: { + /** + * 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. + */ + fetch( + params?: FetchUnreadCountParams, + meta?: HttpMetaParams, + ): Promise<{ count: number }>; + }; } diff --git a/src/mastodon/rest/v1/status-repository.ts b/src/mastodon/rest/v1/status-repository.ts index 8d90943c3..4e1de156b 100644 --- a/src/mastodon/rest/v1/status-repository.ts +++ b/src/mastodon/rest/v1/status-repository.ts @@ -12,6 +12,11 @@ import { } from "../../entities/v1"; import { type Paginator } from "../../paginator"; +export interface FetchStatusesParams { + /** The IDs of the Statuses in the database. */ + readonly id: readonly string[]; +} + export interface CreateStatusParamsBase { /** ID of the status being replied to, if status is a reply */ readonly inReplyToId?: string | null; @@ -90,6 +95,11 @@ export interface TranslateStatusParams { } export interface StatusRepository { + /** + * Obtain information about multiple statuses. + */ + fetch(params: FetchStatusesParams, meta?: HttpMetaParams): Promise; + /** * Post a new status. * @param params Parameters diff --git a/src/mastodon/rest/v1/timeline-repository.ts b/src/mastodon/rest/v1/timeline-repository.ts index c60537c04..1380323be 100644 --- a/src/mastodon/rest/v1/timeline-repository.ts +++ b/src/mastodon/rest/v1/timeline-repository.ts @@ -12,6 +12,11 @@ export interface ListTimelineParams extends DefaultPaginationParams { readonly remote?: boolean | null; } +export interface ListLinkTimelineParams extends ListTimelineParams { + /** The URL of the trending article. */ + readonly url: string; +} + export interface TimelineRepository { home: { /** @@ -83,4 +88,16 @@ export interface TimelineRepository { meta?: HttpMetaParams, ): Paginator; }; + + link: { + /** + * View public statuses containing a link to the specified currently-trending article. This only lists statuses from people who have opted in to discoverability features. + * @returns Array of {@link Status} + * @see https://docs.joinmastodon.org/methods/timelines/#link + */ + list( + params: ListLinkTimelineParams, + meta?: HttpMetaParams, + ): Paginator; + }; } diff --git a/src/mastodon/rest/v2/index.ts b/src/mastodon/rest/v2/index.ts index d14fcf9b1..f1f6d82f3 100644 --- a/src/mastodon/rest/v2/index.ts +++ b/src/mastodon/rest/v2/index.ts @@ -1,5 +1,6 @@ export * from "./filter-repository"; export * from "./instance-repository"; export * from "./media-attachment-repository"; +export * from "./notification-repository"; export * from "./search-repository"; export * from "./suggestion-repository"; diff --git a/src/mastodon/rest/v2/notification-repository.ts b/src/mastodon/rest/v2/notification-repository.ts new file mode 100644 index 000000000..b9149ad3c --- /dev/null +++ b/src/mastodon/rest/v2/notification-repository.ts @@ -0,0 +1,139 @@ +import { type HttpMetaParams } from "../../../interfaces"; +import { + type Account, + type GroupedNotificationsResults, + type NotificationGroupType, +} from "../../entities/v1"; +import { + type NotificationPolicy, + type NotificationPolicyType, +} from "../../entities/v2"; +import { type Paginator } from "../../paginator"; +import { type DefaultPaginationParams } from "../../repository"; + +export interface ListNotificationsParams extends DefaultPaginationParams { + /** Types to include in the result. */ + readonly types?: readonly NotificationGroupType[] | null; + /** Types to exclude from the results. */ + readonly excludeTypes?: readonly NotificationGroupType[] | null; + /** Return only notifications received from the specified account. */ + readonly accountId?: 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. + */ + readonly expandAccounts?: "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. + */ + readonly groupedTypes?: readonly NotificationGroupType[] | null; + + /** Whether to include notifications filtered by the user’s NotificationPolicy. Defaults to false. */ + readonly includeFiltered?: boolean; +} + +export interface FetchUnreadCountParams { + /** Maximum number of results to return. Defaults to 100 notifications. Max 1000 notifications. */ + readonly limit?: number; + /** Types of notifications that should count towards unread notifications. */ + readonly types?: readonly NotificationGroupType[]; + /** Types of notifications that should not count towards unread notifications. */ + readonly excludeTypes?: readonly NotificationGroupType[]; + /** Only count unread notifications received from the specified account. */ + readonly accountId?: 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. */ + readonly groupedTypes?: readonly NotificationGroupType[]; +} + +export interface UpdateNotificationPolicyParams { + /** Whether to accept, filter or drop notifications from accounts the user is not following. drop will prevent creation of the notification object altogether (without preventing the underlying activity), filter will cause it to be marked as filtered, and accept will not affect its processing. */ + readonly forNotFollowing?: NotificationPolicyType; + /** Whether to accept, filter or drop notifications from accounts that are not following the user. drop will prevent creation of the notification object altogether (without preventing the underlying activity), filter will cause it to be marked as filtered, and accept will not affect its processing. */ + readonly forNotFollowers?: NotificationPolicyType; + /** Whether to accept, filter or drop notifications from accounts created in the past 30 days. drop will prevent creation of the notification object altogether (without preventing the underlying activity), filter will cause it to be marked as filtered, and accept will not affect its processing. */ + readonly forNewAccounts?: NotificationPolicyType; + /** Whether to accept, filter or drop notifications from private mentions. drop will prevent creation of the notification object altogether (without preventing the underlying activity), filter will cause it to be marked as filtered, and accept will not affect its processing. Replies to private mentions initiated by the user, as well as accounts the user follows, are always allowed, regardless of this value. */ + readonly forPrivateMentions?: NotificationPolicyType; + /** Whether to accept, filter or drop notifications from accounts that were limited by a moderator. drop will prevent creation of the notification object altogether (without preventing the underlying activity), filter will cause it to be marked as filtered, and accept will not affect its processing. */ + readonly forLimitedAccounts?: NotificationPolicyType; +} + +export interface NotificationRepository { + /** + * 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. + * + * Notifications of type favourite, follow or reblog with the same type and the same target made in + * a similar timeframe are given a same group_key by the server, and querying this endpoint will + * return aggregated notifications, with only one object per group_key. Other notification types may + * be grouped in the future. The grouped_types parameter should be used by the client to explicitly + * list the types it supports showing grouped notifications for. + */ + list( + params?: ListNotificationsParams, + meta?: HttpMetaParams, + ): Paginator; + + /** + * @param groupKey The group key of the notification group. + */ + $select(groupKey: string): { + /** + * View information about a specific notification group with a given group key. + */ + fetch(meta?: HttpMetaParams): Promise; + + /** + * Dismiss a single notification group from the server. + */ + dismiss(meta?: HttpMetaParams): Promise; + + accounts: { + fetch(meta?: HttpMetaParams): Promise; + }; + }; + + unreadCount: { + /** + * 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. + */ + fetch( + params?: FetchUnreadCountParams, + meta?: HttpMetaParams, + ): Promise<{ count: number }>; + }; + + policy: { + /** + * Notifications filtering policy for the user. + */ + fetch(meta?: HttpMetaParams): Promise; + + /** + * Update the user’s notifications filtering policy. + */ + update( + params: UpdateNotificationPolicyParams, + meta?: HttpMetaParams<"json">, + ): Promise; + }; +} diff --git a/src/mastodon/streaming/event.ts b/src/mastodon/streaming/event.ts index f073ee747..fba0e5f16 100644 --- a/src/mastodon/streaming/event.ts +++ b/src/mastodon/streaming/event.ts @@ -45,6 +45,12 @@ export type AnnouncementDeleteEvent = BaseEvent<"announcement.delete", string>; export type StatusUpdateEvent = BaseEvent<"status.update", Status>; +/** Accepted notification requests have finished merging, and the notifications list should be refreshed. Payload can be ignored. Available since v4.3.0 */ +export type NotificationsMergedEvent = BaseEvent< + "notifications_merged", + undefined +>; + export type Event = | UpdateEvent | DeleteEvent @@ -54,4 +60,5 @@ export type Event = | AnnouncementEvent | AnnouncementReactionEvent | AnnouncementDeleteEvent - | StatusUpdateEvent; + | StatusUpdateEvent + | NotificationsMergedEvent; diff --git a/test-utils/async-next-tick.ts b/test-utils/async-next-tick.ts new file mode 100644 index 000000000..5ecba0352 --- /dev/null +++ b/test-utils/async-next-tick.ts @@ -0,0 +1,5 @@ +export function asyncNextTick(): Promise { + return new Promise((resolve) => { + process.nextTick(resolve); + }); +} diff --git a/tests/rest/v1/conversations.spec.ts b/tests/rest/v1/conversations.spec.ts index b5c7d4c4c..ce9317aed 100644 --- a/tests/rest/v1/conversations.spec.ts +++ b/tests/rest/v1/conversations.spec.ts @@ -1,37 +1,50 @@ import assert from "node:assert"; +import waitForExpect from "@sadams/wait-for-expect"; + import { type mastodon } from "../../../src"; -import { waitForCondition } from "../../../test-utils/wait-for-condition"; describe("conversations", () => { it("interacts with conversations", async () => { await using alice = await sessions.acquire(); await using bob = await sessions.acquire(); - const status = await bob.rest.v1.statuses.create({ - status: `@${alice.acct} Hi alice`, - visibility: "direct", - }); let conversation: mastodon.v1.Conversation | undefined; - await waitForCondition(async () => { - const conversations = await alice.rest.v1.conversations.list(); - conversation = conversations.find((c) => c.lastStatus?.id === status.id); - return conversation != undefined; - }); - - assert(conversation != undefined); - - conversation = await alice.rest.v1.conversations - .$select(conversation.id) - .read(); - expect(conversation.unread).toBe(false); - - conversation = await alice.rest.v1.conversations - .$select(conversation.id) - .unread(); - expect(conversation.unread).toBe(true); - - await alice.rest.v1.conversations.$select(conversation.id).remove(); + try { + await alice.rest.v1.accounts.$select(bob.id).follow(); + await bob.rest.v1.accounts.$select(alice.id).follow(); + + const status = await bob.rest.v1.statuses.create({ + status: `@${alice.acct} Hi alice`, + visibility: "direct", + }); + + await waitForExpect(async () => { + const conversations = await alice.rest.v1.conversations.list(); + conversation = conversations.find( + (c) => c.lastStatus?.id === status.id, + ); + expect(conversation?.accounts.map((a) => a.id)).toContain(bob.id); + }); + + assert(conversation != undefined); + + conversation = await alice.rest.v1.conversations + .$select(conversation.id) + .read(); + expect(conversation.unread).toBe(false); + + conversation = await alice.rest.v1.conversations + .$select(conversation.id) + .unread(); + expect(conversation.unread).toBe(true); + } finally { + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + if (conversation) { + await alice.rest.v1.conversations.$select(conversation.id).remove(); + } + } }); }); diff --git a/tests/rest/v1/notifications.spec.ts b/tests/rest/v1/notifications.spec.ts index c74d05841..9c270f8b4 100644 --- a/tests/rest/v1/notifications.spec.ts +++ b/tests/rest/v1/notifications.spec.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; -import { type mastodon } from "../../../src"; +import waitForExpect from "@sadams/wait-for-expect"; + import { waitForCondition } from "../../../test-utils/wait-for-condition"; it("handles notifications", async () => { @@ -11,14 +12,14 @@ it("handles notifications", async () => { }); try { - let notification: mastodon.v1.Notification | undefined; - - await waitForCondition(async () => { - const notifications = await alice.rest.v1.notifications.list(); - notification = notifications.find((n) => n.status?.id === status.id); - return notification?.status != undefined; + await waitForExpect(async () => { + const unreadCount = await alice.rest.v1.notifications.unreadCount.fetch(); + expect(unreadCount.count).toBe(1); }); + let notifications = await alice.rest.v1.notifications.list(); + let notification = notifications.find((n) => n.status?.id === status.id); + assert(notification != undefined); notification = await alice.rest.v1.notifications .$select(notification.id) @@ -28,9 +29,10 @@ it("handles notifications", async () => { expect(notification.status.id).toBe(status.id); await alice.rest.v1.notifications.$select(notification.id).dismiss(); - const notifications = await alice.rest.v1.notifications.list(); + notifications = await alice.rest.v1.notifications.list(); expect(notifications).not.toContainId(notification.id); } finally { + await alice.rest.v1.notifications.clear(); await bob.rest.v1.statuses.$select(status.id).remove(); } }); diff --git a/tests/rest/v1/timelines.spec.ts b/tests/rest/v1/timelines.spec.ts index c5405c20d..151b3c48b 100644 --- a/tests/rest/v1/timelines.spec.ts +++ b/tests/rest/v1/timelines.spec.ts @@ -72,4 +72,6 @@ describe("timeline", () => { }); test.todo("returns direct"); + + test.todo("returns link (hard to emulate)"); }); diff --git a/tests/rest/v2/notifications.spec.ts b/tests/rest/v2/notifications.spec.ts new file mode 100644 index 000000000..ee41af0df --- /dev/null +++ b/tests/rest/v2/notifications.spec.ts @@ -0,0 +1,251 @@ +import waitForExpect from "@sadams/wait-for-expect"; + +import { type mastodon } from "../../../src"; + +describe("notification group", () => { + it("lists, fetches, counts unread, and dismisses", async () => { + await using alice = await sessions.acquire(); + await using bob = await sessions.acquire(); + + try { + await bob.rest.v1.accounts.$select(alice.id).follow(); + + await waitForExpect(async () => { + const unreadCount = + await alice.rest.v2.notifications.unreadCount.fetch(); + expect(unreadCount.count).toBe(1); + }); + + const notifications = await alice.rest.v2.notifications.list(); + expect(notifications.notificationGroups[0].type).toEqual("follow"); + expect(notifications.accounts.map((a) => a.id)).toContain(bob.id); + + const groupKey = notifications.notificationGroups[0].groupKey; + const notification = await alice.rest.v2.notifications + .$select(groupKey) + .fetch(); + expect(notification.notificationGroups[0].type).toEqual("follow"); + expect(notification.accounts.map((a) => a.id)).toContain(bob.id); + + const accounts = await alice.rest.v2.notifications + .$select(groupKey) + .accounts.fetch(); + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toEqual(bob.id); + + await alice.rest.v2.notifications.$select(groupKey).dismiss(); + } finally { + await alice.rest.v1.notifications.clear(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + } + }); +}); + +describe("notifications policy", () => { + it("handles", async () => { + await using alice = await sessions.acquire(); + const originalPolicy = await alice.rest.v2.notifications.policy.fetch(); + + try { + const updatedPolicy = await alice.rest.v2.notifications.policy.update({ + forNewAccounts: "drop", + forNotFollowers: "filter", + forNotFollowing: "drop", + forPrivateMentions: "filter", + forLimitedAccounts: "drop", + }); + + expect(updatedPolicy).toEqual( + expect.objectContaining({ + forNewAccounts: "drop", + forNotFollowers: "filter", + forNotFollowing: "drop", + forPrivateMentions: "filter", + forLimitedAccounts: "drop", + }), + ); + } finally { + await alice.rest.v2.notifications.policy.update({ + forNewAccounts: originalPolicy.forNewAccounts, + forNotFollowers: originalPolicy.forNotFollowers, + forNotFollowing: originalPolicy.forNotFollowing, + forPrivateMentions: originalPolicy.forPrivateMentions, + forLimitedAccounts: originalPolicy.forLimitedAccounts, + }); + } + }); +}); + +describe("notification requests", () => { + it("fetches notification requests", async () => { + await using alice = await sessions.acquire(); + await using bob = await sessions.acquire(); + + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + + try { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "filter", + }); + + await bob.rest.v1.statuses.create({ status: `@${alice.acct} Hello` }); + + let requests!: mastodon.v1.NotificationRequest[]; + await waitForExpect(async () => { + requests = await alice.rest.v1.notifications.requests.list(); + expect(requests).toHaveLength(1); + }); + + const request = await alice.rest.v1.notifications.requests + .$select(requests[0].id) + .fetch(); + await alice.rest.v1.notifications.requests.$select(request.id).dismiss(); + + expect(request.account.id).toEqual(bob.id); + } finally { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "accept", + }); + await alice.rest.v1.accounts.$select(bob.id).block(); + await alice.rest.v1.accounts.$select(bob.id).unblock(); + await bob.rest.v1.accounts.$select(alice.id).block(); + await bob.rest.v1.accounts.$select(alice.id).unblock(); + } + }); + + it("accepts all", async () => { + await using alice = await sessions.acquire(); + await using bob = await sessions.acquire(); + + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + + try { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "filter", + }); + + await bob.rest.v1.statuses.create({ + status: `@${alice.acct} hello`, + }); + + await alice.rest.v1.notifications.requests.accept(); + } finally { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "accept", + }); + await alice.rest.v1.accounts.$select(bob.id).block(); + await alice.rest.v1.accounts.$select(bob.id).unblock(); + await bob.rest.v1.accounts.$select(alice.id).block(); + await bob.rest.v1.accounts.$select(alice.id).unblock(); + } + }); + + it("dismiss all", async () => { + await using alice = await sessions.acquire(); + await using bob = await sessions.acquire(); + + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + + try { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "filter", + }); + + await bob.rest.v1.statuses.create({ + status: `@${alice.acct} hello`, + }); + + await alice.rest.v1.notifications.requests.accept(); + } finally { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "accept", + }); + await alice.rest.v1.accounts.$select(bob.id).block(); + await alice.rest.v1.accounts.$select(bob.id).unblock(); + await bob.rest.v1.accounts.$select(alice.id).block(); + await bob.rest.v1.accounts.$select(alice.id).unblock(); + } + }); + + it("checks if merged", async () => { + await using alice = await sessions.acquire(); + const merged = await alice.rest.v1.notifications.requests.merged.fetch(); + expect(merged.merged).toBe(true); + }); + + it("accept single", async () => { + await using alice = await sessions.acquire(); + await using bob = await sessions.acquire(); + + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + + try { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "filter", + }); + + await bob.rest.v1.statuses.create({ + status: `@${alice.acct} hello`, + }); + + let requests!: mastodon.v1.NotificationRequest[]; + await waitForExpect(async () => { + requests = await alice.rest.v1.notifications.requests.list(); + expect(requests).toHaveLength(1); + }); + + await alice.rest.v1.notifications.requests + .$select(requests[0].id) + .dismiss(); + } finally { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "accept", + }); + await alice.rest.v1.accounts.$select(bob.id).block(); + await alice.rest.v1.accounts.$select(bob.id).unblock(); + await bob.rest.v1.accounts.$select(alice.id).block(); + await bob.rest.v1.accounts.$select(alice.id).unblock(); + } + }); + + it("dismiss single", async () => { + await using alice = await sessions.acquire(); + await using bob = await sessions.acquire(); + + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); + + try { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "filter", + }); + + await bob.rest.v1.statuses.create({ + status: `@${alice.acct} hello`, + }); + + let requests!: mastodon.v1.NotificationRequest[]; + await waitForExpect(async () => { + requests = await alice.rest.v1.notifications.requests.list(); + expect(requests).toHaveLength(1); + }); + + await alice.rest.v1.notifications.requests + .$select(requests[0].id) + .dismiss(); + } finally { + await alice.rest.v2.notifications.policy.update({ + forNotFollowing: "accept", + }); + await alice.rest.v1.accounts.$select(bob.id).block(); + await alice.rest.v1.accounts.$select(bob.id).unblock(); + await bob.rest.v1.accounts.$select(alice.id).block(); + await bob.rest.v1.accounts.$select(alice.id).unblock(); + } + }); +}); diff --git a/tests/streaming/events.spec.ts b/tests/streaming/events.spec.ts index 1a52a1a43..16f7abf7a 100644 --- a/tests/streaming/events.spec.ts +++ b/tests/streaming/events.spec.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import crypto from "node:crypto"; import { sleep } from "../../src/utils"; +import { asyncNextTick } from "../../test-utils/async-next-tick"; describe("events", () => { it("streams update, status.update, and delete event", async () => { @@ -30,23 +31,24 @@ describe("events", () => { expect(e3.payload).toBe(status.id); }); - it("streams filters_changed event", async () => { - await using session = await sessions.acquire({ waitForWs: true }); - using subscription = session.ws.user.subscribe(); - const eventsPromise = subscription.values().take(1).toArray(); - - const filter = await session.rest.v2.filters.create({ - title: "test", - context: ["public"], - keywordsAttributes: [{ keyword: "TypeScript" }], - }); - await sleep(1000); - await session.rest.v2.filters.$select(filter.id).remove(); - - const [e] = await eventsPromise; - assert(e.event === "filters_changed"); - expect(e.payload).toBeUndefined(); - }); + test.todo("streams filters_changed event"); + // it("streams filters_changed event", async () => { + // await using session = await sessions.acquire({ waitForWs: true }); + // using subscription = session.ws.user.subscribe(); + // const eventsPromise = subscription.values().take(1).toArray(); + + // const filter = await session.rest.v2.filters.create({ + // title: "test", + // context: ["public"], + // keywordsAttributes: [{ keyword: "TypeScript" }], + // }); + // await sleep(1000); + // await session.rest.v2.filters.$select(filter.id).remove(); + + // const [e] = await eventsPromise; + // assert(e.event === "filters_changed"); + // expect(e.payload).toBeUndefined(); + // }); it("streams notification", async () => { await using alice = await sessions.acquire({ waitForWs: true }); @@ -54,6 +56,7 @@ describe("events", () => { using subscription = alice.ws.user.notification.subscribe(); const eventsPromise = subscription.values().take(1).toArray(); + await asyncNextTick(); await bob.rest.v1.accounts.$select(alice.id).follow(); @@ -72,6 +75,10 @@ describe("events", () => { using subscription = alice.ws.direct.subscribe(); const eventsPromise = subscription.values().take(1).toArray(); + await asyncNextTick(); + + await alice.rest.v1.accounts.$select(bob.id).follow(); + await bob.rest.v1.accounts.$select(alice.id).follow(); const status = await bob.rest.v1.statuses.create({ status: `@${alice.acct} Hello there`, @@ -84,8 +91,12 @@ describe("events", () => { expect(e.payload.lastStatus?.id).toBe(status.id); } finally { await bob.rest.v1.statuses.$select(status.id).remove(); + await alice.rest.v1.accounts.$select(bob.id).unfollow(); + await bob.rest.v1.accounts.$select(alice.id).unfollow(); } }); test.todo("announcement"); + + test.todo("notifications_merged"); });