diff --git a/docs/screenshots/point-received-notification-dark.png b/docs/screenshots/point-received-notification-dark.png new file mode 100644 index 00000000000..1b934bfb430 Binary files /dev/null and b/docs/screenshots/point-received-notification-dark.png differ diff --git a/docs/screenshots/point-received-notification-light.png b/docs/screenshots/point-received-notification-light.png new file mode 100644 index 00000000000..cfb68a8bb78 Binary files /dev/null and b/docs/screenshots/point-received-notification-light.png differ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c3789c3900b..3a61edcbeca 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3259,6 +3259,8 @@ _notification: flushNotification: "通知の履歴をリセットする" exportOfXCompleted: "{x}のエクスポートが完了しました" login: "ログインがありました" + pointReceived: "{points}{pointName}を受け取りました" + pointReceivedFrom: "{name}さんから{points}{pointName}を受け取りました" loginBonus: "ログインボーナス" createToken: "アクセストークンが作成されました" createTokenDescription: "心当たりがない場合は「{text}」を通じてアクセストークンを削除してください。" @@ -3282,6 +3284,7 @@ _notification: achievementEarned: "実績の獲得" exportCompleted: "エクスポートが完了した" login: "ログイン" + pointReceived: "ポイントを受け取った" loginBonus: "ログインボーナス" createToken: "アクセストークンの作成" test: "通知のテスト" diff --git a/packages/backend/src/core/PointService.ts b/packages/backend/src/core/PointService.ts index 693d377b05d..b1173548a08 100644 --- a/packages/backend/src/core/PointService.ts +++ b/packages/backend/src/core/PointService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -14,6 +15,8 @@ export class PointService { constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, + + private notificationService: NotificationService, ) { } @@ -62,6 +65,10 @@ export class PointService { points: newRecipientBalance, }); + this.notificationService.createNotification(recipientId, 'pointReceived', { + points: amount, + }, senderId); + return { success: true, senderBalance: newSenderBalance, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 65930c3d690..f28251a98f8 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -194,7 +194,7 @@ export class NotificationEntityService implements OnModuleInit { header: notification.customHeader, icon: notification.customIcon, } : {}), - ...(notification.type === 'loginBonus' ? { + ...(notification.type === 'loginBonus' || notification.type === 'pointReceived' ? { points: notification.points, } : {}), }); diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index e7ddae29026..8a195a0bfae 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -142,6 +142,12 @@ export type MiNotification = { type: 'test'; id: string; createdAt: string; +} | { + type: 'pointReceived'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + points: number; } | { type: 'loginBonus'; id: string; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 30e9c9327a1..0a001b52a27 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -237,6 +237,30 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['pointReceived'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + points: { + type: 'number', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 3e442eccc5f..48f36f35093 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -629,6 +629,7 @@ export const packedMeDetailedOnlySchema = { test: { optional: true, ...notificationRecieveConfig }, login: { optional: true, ...notificationRecieveConfig }, createToken: { optional: true, ...notificationRecieveConfig }, + pointReceived: { optional: true, ...notificationRecieveConfig }, exportCompleted: { optional: true, ...notificationRecieveConfig }, }, }, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 035234ea4aa..0b066f19503 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -216,6 +216,7 @@ export const paramDef = { roleAssigned: notificationRecieveConfig, chatRoomInvitationReceived: notificationRecieveConfig, achievementEarned: notificationRecieveConfig, + pointReceived: notificationRecieveConfig, app: notificationRecieveConfig, test: notificationRecieveConfig, }, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index a91a766482d..438f1314dac 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -44,6 +44,7 @@ export const notificationTypes = [ 'exportCompleted', 'login', 'createToken', + 'pointReceived', 'app', 'test', 'loginBonus', diff --git a/packages/backend/test/unit/PointService.ts b/packages/backend/test/unit/PointService.ts new file mode 100644 index 00000000000..ae74a688a73 --- /dev/null +++ b/packages/backend/test/unit/PointService.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, test } from 'vitest'; +import { mockDeep } from 'vitest-mock-extended'; +import type { UsersRepository, MiUser } from '@/models/_.js'; +import { PointService } from '@/core/PointService.js'; +import { NotificationService } from '@/core/NotificationService.js'; + +describe('PointService', () => { + const usersRepository = mockDeep(); + const notificationService = mockDeep(); + const service = new PointService(usersRepository, notificationService); + + beforeEach(() => { + usersRepository.findOneByOrFail.mockReset(); + usersRepository.update.mockReset(); + notificationService.createNotification.mockReset(); + usersRepository.findOneByOrFail.mockResolvedValueOnce({ id: 'sender', points: 100 } as MiUser); + usersRepository.findOneByOrFail.mockResolvedValueOnce({ id: 'recipient', points: 20 } as MiUser); + }); + + test('notifies the recipient of the sender and amount after transferring points', async () => { + const result = await service.sendPoints('sender', 'recipient', 30); + + expect(result).toEqual({ success: true, senderBalance: 70, recipientBalance: 50 }); + expect(usersRepository.update).toHaveBeenCalledWith('sender', { points: 70 }); + expect(usersRepository.update).toHaveBeenCalledWith('recipient', { points: 50 }); + expect(notificationService.createNotification).toHaveBeenCalledExactlyOnceWith( + 'recipient', 'pointReceived', { points: 30 }, 'sender', + ); + expect(notificationService.createNotification.mock.invocationCallOrder[0]).toBeGreaterThan( + usersRepository.update.mock.invocationCallOrder[1], + ); + }); + + test('does not notify when the sender has insufficient points', async () => { + await expect(service.sendPoints('sender', 'recipient', 101)).rejects.toThrow('Insufficient points'); + expect(notificationService.createNotification).not.toHaveBeenCalled(); + }); + + test('does not notify when the balance update fails', async () => { + usersRepository.update.mockRejectedValueOnce(new Error('update failed')); + await expect(service.sendPoints('sender', 'recipient', 30)).rejects.toThrow('update failed'); + expect(notificationService.createNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 643383368c2..c33bb95634f 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted', [$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed', [$style.t_achievementEarned]: notification.type === 'achievementEarned', - [$style.t_loginBonus]: notification.type === 'loginBonus', + [$style.t_loginBonus]: notification.type === 'loginBonus' || notification.type === 'pointReceived', [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', [$style.t_createToken]: notification.type === 'createToken', @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -77,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._notification.createToken }} {{ i18n.ts._notification.testNotification }} {{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }} - + {{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }} {{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }} {{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }} @@ -126,6 +126,9 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._achievements._types[`_${notification.achievement}`].title }} +
+ {{ i18n.tsx._notification.pointReceived({ points: notification.points, pointName: instance.pointName ?? i18n.ts.point }) }} +
+{{ notification.points }} {{ instance.pointName ?? i18n.ts.point }}
diff --git a/packages/frontend/test/unit/point-received-notification.test.ts b/packages/frontend/test/unit/point-received-notification.test.ts new file mode 100644 index 00000000000..4db70c14f0d --- /dev/null +++ b/packages/frontend/test/unit/point-received-notification.test.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, expect, test, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/vue'; +import type * as Misskey from 'misskey-js'; +import MkNotification from '@/components/MkNotification.vue'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; + +vi.mock('@/i.js', () => ({ ensureSignin: () => ({ id: 'recipient' }) })); +vi.mock('@/instance.js', () => ({ instance: { pointName: 'コイン' } })); +vi.mock('@/utility/misskey-api.js', () => ({ misskeyApi: vi.fn() })); +vi.mock('@/components/MkReactionIcon.vue', () => ({ default: { template: '' } })); +vi.mock('@/components/MkButton.vue', () => ({ default: { template: '' } })); + +afterEach(cleanup); + +test.each(['コイン', null])('shows the sender profile and received amount with point name %s', (pointName) => { + instance.pointName = pointName; + const notification = { + id: 'gift', createdAt: '2026-10-06T00:00:00.000Z', type: 'pointReceived', points: 30, + userId: 'sender', user: { id: 'sender', name: '贈り主', username: 'sender', host: null }, + } as Misskey.entities.Notification; + const view = render(MkNotification, { + props: { notification }, + global: { + stubs: { + MkA: { props: ['to'], template: '' }, + MkUserName: { props: ['user'], template: '{{ user.name }}' }, + MkAvatar: { props: ['user'], template: '' }, + }, + directives: { 'user-preview': {} }, + }, + }); + + expect(view.getByRole('link', { name: '贈り主' }).getAttribute('href')).toBe('/@sender'); + expect(view.container.querySelector('[data-avatar-user="sender"]')).not.toBeNull(); + expect(view.getByText(i18n.tsx._notification.pointReceived({ points: 30, pointName: pointName ?? i18n.ts.point }))).toBeTruthy(); +}); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 9cb14ed5781..3269c10b86b 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -12397,6 +12397,14 @@ export interface Locale extends ILocale { * ログインがありました */ "login": string; + /** + * {points}{pointName}を受け取りました + */ + "pointReceived": ParameterizedString<"points" | "pointName">; + /** + * {name}さんから{points}{pointName}を受け取りました + */ + "pointReceivedFrom": ParameterizedString<"name" | "points" | "pointName">; /** * ログインボーナス */ @@ -12482,6 +12490,10 @@ export interface Locale extends ILocale { * ログイン */ "login": string; + /** + * ポイントを受け取った + */ + "pointReceived": string; /** * ログインボーナス */ diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index d92b22ea75e..dfc65dcf144 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -3825,7 +3825,7 @@ type Notification_2 = components['schemas']['Notification']; type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; // @public (undocumented) -export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"]; +export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken", "pointReceived"]; // @public (undocumented) export function nyaize(text: string): string; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 088ad1b4dde..07c5ba8e1c4 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5011,6 +5011,15 @@ export type components = { /** Format: misskey:id */ userListId: string; }; + pointReceived?: { + /** @enum {string} */ + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; + } | { + /** @enum {string} */ + type: 'list'; + /** Format: misskey:id */ + userListId: string; + }; exportCompleted?: { /** @enum {string} */ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; @@ -5376,6 +5385,17 @@ export type components = { /** @enum {string} */ type: 'scheduledNotePostFailed'; noteDraft: components['schemas']['NoteDraft']; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'pointReceived'; + user: components['schemas']['UserLite']; + /** Format: id */ + userId: string; + points: number; } | { /** Format: id */ id: string; @@ -32500,8 +32520,8 @@ export interface operations { untilDate?: number; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; }; }; }; @@ -32585,8 +32605,8 @@ export interface operations { untilDate?: number; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[]; }; }; }; @@ -33925,6 +33945,15 @@ export interface operations { /** Format: misskey:id */ userListId: string; }; + pointReceived?: { + /** @enum {string} */ + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; + } | { + /** @enum {string} */ + type: 'list'; + /** Format: misskey:id */ + userListId: string; + }; app?: { /** @enum {string} */ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index aa64c208a3d..84aa38feb30 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -37,6 +37,7 @@ export const notificationTypes = [ 'test', 'login', 'createToken', + 'pointReceived', ] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index 6dfea12aa8b..1ab22821398 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -52,6 +52,16 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif */ case 'notification': switch (data.body.type) { + case 'pointReceived': + return [i18n.tsx._notification.pointReceivedFrom({ + name: getUserName(data.body.user), + points: data.body.points, + pointName: i18n.ts.point, + }), { + icon: data.body.user.avatarUrl ?? undefined, + data, + }]; + case 'follow': { // users/showの型定義をswos.apiへ当てはめるのが困難なのでapiFetch.requestを直接使用 const account = await getAccountFromId(data.userId);