Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1220,7 +1220,9 @@ pinnedList: "ピン留めされたリスト"
keepScreenOn: "デバイスの画面を常にオンにする"
verifiedLink: "このリンク先の所有者であることが確認されました"
notifyNotes: "投稿を通知"
unnotifyNotes: "投稿の通知を解除"
notifyAllNotes: "すべての投稿を通知"
notifyNotesWithFiles: "ファイル付き投稿のみ通知"
unnotifyNotes: "通知しない"
notifyUsers: "投稿通知を設定したユーザー"
authentication: "認証"
authenticationRequiredToContinue: "続けるには認証を行ってください"
Expand Down
11 changes: 7 additions & 4 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,10 +770,11 @@ export class NoteCreateService implements OnApplicationShutdown {
// TODO: キャッシュ
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
notify: In(['normal', 'withFile']),
}).then(async followings => {
if (note.visibility !== 'specified') {
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
const hasFiles = data.files != null && data.files.length > 0;
for (const following of followings) {
// TODO: ワードミュート考慮
let isRenoteMuted = false;
Expand All @@ -782,9 +783,11 @@ export class NoteCreateService implements OnApplicationShutdown {
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
}
if (!isRenoteMuted) {
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
if (following.notify === 'normal' || (following.notify === 'withFile' && hasFiles)) {
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/models/Following.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class MiFollowing {
length: 32,
nullable: true,
})
public notify: 'normal' | null;
public notify: 'normal' | 'withFile' | null;

//#region Denormalized fields
@Index()
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/models/json-schema/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ export const packedUserDetailedNotMeOnlySchema = {
notify: {
type: 'string',
nullable: false, optional: true,
enum: ['normal', 'none'],
enum: ['normal', 'withFile', 'none'],
},
withReplies: {
type: 'boolean',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
notify: { type: 'string', enum: ['normal', 'none'] },
notify: { type: 'string', enum: ['normal', 'withFile', 'none'] },
withReplies: { type: 'boolean' },
},
} as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
notify: { type: 'string', enum: ['normal', 'none'] },
notify: { type: 'string', enum: ['normal', 'withFile', 'none'] },
withReplies: { type: 'boolean' },
},
required: ['userId'],
Expand Down
107 changes: 106 additions & 1 deletion packages/backend/test/e2e/note-notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@
import * as assert from 'node:assert';
import { setTimeout } from 'node:timers/promises';
import { describe, beforeAll, test } from 'vitest';
import { api, signup } from '../utils.js';
import { api, signup, uploadUrl } from '../utils.js';
import type * as misskey from 'misskey-js';

describe('following/list', () => {
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
let carol: misskey.entities.SignupResponse;
let dave: misskey.entities.SignupResponse;

beforeAll(async () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
dave = await signup({ username: 'dave' });
}, 1000 * 60 * 2);

test('通知設定なしのフォローのみの場合、空配列が返る', async () => {
Expand Down Expand Up @@ -64,6 +66,35 @@ describe('following/list', () => {
assert.deepStrictEqual(ids, [bob.id, carol.id].sort());
});

test('withFile 設定のユーザーも notification: true な一覧に含まれる', async () => {
// alice が dave をフォローして withFile 通知ON
await api('following/create', { userId: dave.id }, alice);
await api('following/update', { userId: dave.id, notify: 'withFile' }, alice);

const res = await api('following/list', { notification: true }, alice);

assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.length, 3);

const ids = res.body.map((u) => u.followeeId).sort();
assert.deepStrictEqual(ids, [bob.id, carol.id, dave.id].sort());
});

test('withFile から none に変更すると notification: true な一覧から外れる', async () => {
await api('following/update', { userId: dave.id, notify: 'none' }, alice);

const res = await api('following/list', { notification: true }, alice);

assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.length, 2);

const ids = res.body.map((u) => u.followeeId);
assert.strictEqual(ids.includes(dave.id), false);

// 後片付け: alice → dave のフォローを解除
await api('following/delete', { userId: dave.id }, alice);
});

test('通知設定をOFF(none)にすると notification: true な一覧から外れる', async () => {
await api('following/update', { userId: bob.id, notify: 'none' }, alice);

Expand Down Expand Up @@ -125,6 +156,54 @@ describe('following/list', () => {
await api('notifications/mark-all-as-read', {}, alice);
});

test('withFile通知設定時、ファイル付き投稿で通知が届く', async () => {
await api('following/update', { userId: bob.id, notify: 'withFile' }, alice);

// 既存の通知をクリア
await api('notifications/mark-all-as-read', {}, alice);

// --- ケース1: テキストのみの投稿 → 通知が来ないこと ---
const textOnlyRes = await api('notes/create', {
text: 'ファイルなしの投稿',
}, bob);
assert.strictEqual(textOnlyRes.status, 200);

// redisに追加されるのを待つ
await setTimeout(100);

const beforeRes = await api('i/notifications', {}, alice);
assert.strictEqual(beforeRes.status, 200);
const noteNotifsBefore = beforeRes.body.filter((n: { type: string; note?: { id: string } }) =>
n.type === 'note' && n.note?.id === textOnlyRes.body.createdNote.id,
);
assert.strictEqual(noteNotifsBefore.length, 0, 'ファイルなし投稿で通知が来てしまった');

// --- ケース2: ファイル付き投稿 → 通知が来ること ---
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg');

const fileNoteRes = await api('notes/create', {
fileIds: [file.id],
}, bob);
assert.strictEqual(fileNoteRes.status, 200);
assert.deepStrictEqual(fileNoteRes.body.createdNote.fileIds, [file.id]);

// redisに追加されるのを待つ
await setTimeout(100);

const res = await api('i/notifications', {}, alice);
Comment on lines +171 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- note-notify.ts around cited lines ---'
sed -n '130,230p' packages/backend/test/e2e/note-notify.ts

echo
echo '--- search for notification polling / retry helpers ---'
rg -n "setTimeout\(100\)|poll|retry|waitFor|eventually|notifications" packages/backend/test -g '*.ts'

Repository: misskey-dev/misskey

Length of output: 34789


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- file stats ---'
wc -l packages/backend/test/e2e/note-notify.ts

echo
echo '--- candidate helper files ---'
git ls-files packages/backend/test | sed -n '1,120p'

Repository: misskey-dev/misskey

Length of output: 5135


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- search for setTimeout imports/usages in backend e2e tests ---'
rg -n "import .*setTimeout|setTimeout\(" packages/backend/test/e2e -g '*.ts'

echo
echo '--- inspect test utilities for polling helpers ---'
rg -n "waitFor|retry|poll|sleep|delay|settle|promise.*timeout" packages/backend/test packages/backend/src -g '*.ts'

Repository: misskey-dev/misskey

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '150,220p' packages/backend/test/e2e/note-notify.ts

Repository: misskey-dev/misskey

Length of output: 2658


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- note creation notification call sites ---'
rg -n "createNotification\(|notificationService\." packages/backend/src/core/NoteCreateService.ts packages/backend/src/queue packages/backend/src/core -g '*.ts' | sed -n '1,120p'

echo
echo '--- note-notify test imports ---'
sed -n '1,40p' packages/backend/test/e2e/note-notify.ts

Repository: misskey-dev/misskey

Length of output: 5184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- relevant note create service slice ---'
sed -n '760,860p' packages/backend/src/core/NoteCreateService.ts

echo
echo '--- notification queue / processor slice if any ---'
rg -n "publishNoteStream|queue.*notification|Notification" packages/backend/src/queue packages/backend/src/core -g '*.ts' | sed -n '1,120p'

Repository: misskey-dev/misskey

Length of output: 19021


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '780,840p' packages/backend/src/core/NoteCreateService.ts

Repository: misskey-dev/misskey

Length of output: 2060


Replace the fixed sleeps with polling in packages/backend/test/e2e/note-notify.ts. notes/create queues notification creation asynchronously, so await setTimeout(100) can still read too early or let a late notification race past the negative check. Use vi.waitFor or another bounded wait on i/notifications instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/test/e2e/note-notify.ts` around lines 168 - 190, Replace
both fixed setTimeout(100) delays in the note notification test with bounded
polling of i/notifications using vi.waitFor or the test’s established
equivalent. Ensure the first poll confirms no notification for the file-less
note before proceeding, and the second waits until the file-backed note
notification appears, while preserving the existing assertions and API calls.

assert.strictEqual(res.status, 200);

const noteNotif = res.body.filter((n: { type: string; note?: { id: string } }) =>
n.type === 'note' && n.note?.id === fileNoteRes.body.createdNote.id,
);

assert.strictEqual(noteNotif.length, 1, 'ファイル付き投稿の通知が届かなかった');

// 後片付け
await api('following/update', { userId: bob.id, notify: 'none' }, alice);
await api('notifications/mark-all-as-read', {}, alice);
});

test('limit パラメータが効く', async () => {
// limit テスト用に bob を再度ONにして2件状態を作る
await api('following/update', { userId: bob.id, notify: 'normal' }, alice);
Expand All @@ -136,8 +215,34 @@ describe('following/list', () => {

// limit:1 で1件に絞られることを確認
const res = await api('following/list', { notification: true, limit: 1 }, alice);
});

test('untilId パラメータが効く', async () => {
const allRes = await api('following/list', { notification: true }, alice);
assert.strictEqual(allRes.status, 200);
assert.strictEqual(allRes.body.length, 2);

const newerId = allRes.body[0].id;
const olderId = allRes.body[1].id;

const res = await api('following/list', { notification: true, untilId: newerId }, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, olderId);
});

test('sinceId パラメータが効く', async () => {
const allRes = await api('following/list', { notification: true }, alice);
assert.strictEqual(allRes.status, 200);
assert.strictEqual(allRes.body.length, 2);

const newerId = allRes.body[0].id;
const olderId = allRes.body[1].id;

const res = await api('following/list', { notification: true, sinceId: olderId }, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, newerId);
});

test('未認証の場合はエラー', async () => {
Expand Down
17 changes: 9 additions & 8 deletions packages/frontend/src/pages/settings/notifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,19 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
const $i = ensureSignin();

async function showNotifyMenu(user: Misskey.entities.UserDetailed, ev: PointerEvent) {
os.popupMenu([{
text: (user.notify === 'normal') ? i18n.ts.unnotifyNotes : i18n.ts.notifyNotes,
icon: (user.notify === 'normal') ? 'ti ti-x' : 'ti ti-plus',
action: async () => {
await os.apiWithDialog('following/update', {
os.popupMenu((['normal', 'withFile', 'none'] as const).map(v => ({
type: 'radioOption',
text: v === 'normal' ? i18n.ts.notifyAllNotes : v === 'withFile' ? i18n.ts.notifyNotesWithFiles : i18n.ts.unnotifyNotes,
active: computed(() => user.notify === v),
action: () => {
os.apiWithDialog('following/update', {
userId: user.id,
notify: user.notify === 'normal' ? 'none' : 'normal',
notify: v,
}).then(() => {
user.notify = user.notify === 'normal' ? 'none' : 'normal';
user.notify = v;
});
},
}], ev.currentTarget ?? ev.target);
})), ev.currentTarget ?? ev.target);
}

const notifyUserPaginator = markRaw(new Paginator('following/list', {
Expand Down
32 changes: 19 additions & 13 deletions packages/frontend/src/utility/get-user-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { toUnicode } from 'punycode.js';
import { defineAsyncComponent, ref, watch } from 'vue';
import { defineAsyncComponent, ref, watch, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { host, url } from '@@/js/config.js';
import type { Router } from '@/router.js';
Expand Down Expand Up @@ -85,15 +85,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
});
}

async function toggleNotify() {
os.apiWithDialog('following/update', {
userId: user.id,
notify: user.notify === 'normal' ? 'none' : 'normal',
}).then(() => {
user.notify = user.notify === 'normal' ? 'none' : 'normal';
});
}

async function reportAbuse() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: user,
Expand Down Expand Up @@ -365,9 +356,24 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withRepliesRef,
}, {
icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
action: toggleNotify,
icon: 'ti ti-bell',
text: i18n.ts.notifyNotes,
type: 'parent',
children: async () => {
return (['normal', 'withFile', 'none'] as const).map(v => ({
type: 'radioOption',
text: v === 'normal' ? i18n.ts.notifyAllNotes : v === 'withFile' ? i18n.ts.notifyNotesWithFiles : i18n.ts.unnotifyNotes,
active: computed(() => user.notify === v),
action: () => {
os.apiWithDialog('following/update', {
userId: user.id,
notify: v,
}).then(() => {
user.notify = v;
});
},
}));
},
});

watch(withRepliesRef, (withReplies) => {
Expand Down
10 changes: 9 additions & 1 deletion packages/i18n/src/autogen/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4893,7 +4893,15 @@ export interface Locale extends ILocale {
*/
"notifyNotes": string;
/**
* 投稿の通知を解除
* すべての投稿を通知
*/
"notifyAllNotes": string;
/**
* ファイル付き投稿のみ通知
*/
"notifyNotesWithFiles": string;
/**
* 通知しない
*/
"unnotifyNotes": string;
/**
Expand Down
6 changes: 3 additions & 3 deletions packages/misskey-js/src/autogen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4130,7 +4130,7 @@ export type components = {
isMuted?: boolean;
isRenoteMuted?: boolean;
/** @enum {string} */
notify?: 'normal' | 'none';
notify?: 'normal' | 'withFile' | 'none';
withReplies?: boolean;
};
MeDetailedOnly: {
Expand Down Expand Up @@ -23248,7 +23248,7 @@ export interface operations {
/** Format: misskey:id */
userId: string;
/** @enum {string} */
notify?: 'normal' | 'none';
notify?: 'normal' | 'withFile' | 'none';
withReplies?: boolean;
};
};
Expand Down Expand Up @@ -23324,7 +23324,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
notify?: 'normal' | 'none';
notify?: 'normal' | 'withFile' | 'none';
withReplies?: boolean;
};
};
Expand Down
Loading