parent
4299588806
commit
bf40bc4fe3
@ -1,5 +1,6 @@
|
||||
# DIFFRENCE
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
## 2024.9.0-yami-1.3.1
|
||||
## Client
|
||||
- フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に
|
||||
@ -9,12 +10,17 @@
|
||||
## Feat
|
||||
- ロールで引用通知の設定を制限出来るように
|
||||
|
||||
=======
|
||||
>>>>>>> 3c53bbbac6 (Merge pull request #40 from lqvp/master)
|
||||
## 2024.9.0-yami-1.2.9
|
||||
## Feat
|
||||
- ノート数を隠せるように(連合しません)
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
>>>>>>> 00cf91cf30 (Merge pull request #39 from lqvp/master)
|
||||
=======
|
||||
>>>>>>> 3c53bbbac6 (Merge pull request #40 from lqvp/master)
|
||||
## 2024.9.0-yami-1.2.8
|
||||
## Feat
|
||||
- Cherry-Pick アクティビティの非公開機能(hideki0403/kakurega.app)
|
||||
|
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
@ -3788,6 +3788,10 @@ export interface Locale extends ILocale {
|
||||
* スレッドのミュートを解除
|
||||
*/
|
||||
"unmuteThread": string;
|
||||
/**
|
||||
* ノート数の公開範囲
|
||||
*/
|
||||
"notesVisibility": string;
|
||||
/**
|
||||
* フォローの公開範囲
|
||||
*/
|
||||
|
@ -943,6 +943,7 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を
|
||||
classic: "クラシック"
|
||||
muteThread: "スレッドをミュート"
|
||||
unmuteThread: "スレッドのミュートを解除"
|
||||
notesVisibility: "ノート数の公開範囲"
|
||||
followingVisibility: "フォローの公開範囲"
|
||||
followersVisibility: "フォロワーの公開範囲"
|
||||
continueThread: "さらにスレッドを見る"
|
||||
|
22
packages/backend/migration/1702718871542-notesvisibility.js
Normal file
22
packages/backend/migration/1702718871542-notesvisibility.js
Normal file
@ -0,0 +1,22 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class notesvisibility1702718871542 {
|
||||
constructor() {
|
||||
this.name = 'notesvisibility1702718871542';
|
||||
}
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TYPE "public"."user_profile_notesVisibility_enum" AS ENUM('public', 'followers', 'private')`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "notesVisibility" "public"."user_profile_notesVisibility_enum" NOT NULL DEFAULT 'public'`);
|
||||
await queryRunner.query(`UPDATE "user_profile" SET "notesVisibility" = 'public'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "notesVisibility"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."user_profile_notesVisibility_enum"`);
|
||||
}
|
||||
}
|
||||
|
436
packages/backend/src/core/WebhookTestService.ts
Normal file
436
packages/backend/src/core/WebhookTestService.ts
Normal file
@ -0,0 +1,436 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
|
||||
return {
|
||||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
reporterId: 'dummy-reporter-user',
|
||||
reporter: null,
|
||||
assigneeId: null,
|
||||
assignee: null,
|
||||
resolved: false,
|
||||
forwarded: false,
|
||||
comment: 'This is a dummy report for testing purposes.',
|
||||
targetUserHost: null,
|
||||
reporterHost: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
return {
|
||||
id: 'dummy-user-1',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 7),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis * 5),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis * 3),
|
||||
hideOnlineStatus: false,
|
||||
username: 'dummy1',
|
||||
usernameLower: 'dummy1',
|
||||
name: 'DummyUser1',
|
||||
followersCount: 10,
|
||||
followingCount: 5,
|
||||
movedToUri: null,
|
||||
movedAt: null,
|
||||
alsoKnownAs: null,
|
||||
notesCount: 30,
|
||||
avatarId: null,
|
||||
avatar: null,
|
||||
bannerId: null,
|
||||
banner: null,
|
||||
avatarUrl: null,
|
||||
bannerUrl: null,
|
||||
avatarBlurhash: null,
|
||||
bannerBlurhash: null,
|
||||
avatarDecorations: [],
|
||||
tags: [],
|
||||
isSuspended: false,
|
||||
isLocked: false,
|
||||
isBot: false,
|
||||
isCat: true,
|
||||
isRoot: false,
|
||||
isExplorable: true,
|
||||
isHibernated: false,
|
||||
isDeleted: false,
|
||||
emojis: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
inbox: null,
|
||||
sharedInbox: null,
|
||||
featured: null,
|
||||
uri: null,
|
||||
followersUri: null,
|
||||
token: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||
return {
|
||||
id: 'dummy-note-1',
|
||||
replyId: null,
|
||||
reply: null,
|
||||
renoteId: null,
|
||||
renote: null,
|
||||
threadId: null,
|
||||
text: 'This is a dummy note for testing purposes.',
|
||||
name: null,
|
||||
cw: null,
|
||||
userId: 'dummy-user-1',
|
||||
user: null,
|
||||
localOnly: true,
|
||||
reactionAcceptance: 'likeOnly',
|
||||
renoteCount: 10,
|
||||
repliesCount: 5,
|
||||
clippedCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
url: null,
|
||||
fileIds: [],
|
||||
attachedFileTypes: [],
|
||||
visibleUserIds: [],
|
||||
mentions: [],
|
||||
mentionedRemoteUsers: '[]',
|
||||
reactionAndUserPairCache: [],
|
||||
emojis: [],
|
||||
tags: [],
|
||||
hasPoll: false,
|
||||
channelId: null,
|
||||
channel: null,
|
||||
userHost: null,
|
||||
replyUserId: null,
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> {
|
||||
return {
|
||||
id: note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
cw: note.cw,
|
||||
userId: note.userId,
|
||||
user: toPackedUserLite(note.user ?? generateDummyUser()),
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
fileIds: note.fileIds,
|
||||
files: [],
|
||||
tags: note.tags,
|
||||
poll: null,
|
||||
emojis: note.emojis,
|
||||
channelId: note.channelId,
|
||||
channel: note.channel,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
reactionEmojis: {},
|
||||
reactions: {},
|
||||
reactionCount: 0,
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
uri: note.uri ?? undefined,
|
||||
url: note.url ?? undefined,
|
||||
reactionAndUserPairCache: note.reactionAndUserPairCache,
|
||||
...(detail ? {
|
||||
clippedCount: note.clippedCount,
|
||||
reply: note.reply ? toPackedNote(note.reply, false) : null,
|
||||
renote: note.renote ? toPackedNote(note.renote, true) : null,
|
||||
myReaction: null,
|
||||
} : {}),
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||
id: it.id,
|
||||
angle: it.angle,
|
||||
flipH: it.flipH,
|
||||
url: 'https://example.com/dummy-image001.png',
|
||||
offsetX: it.offsetX,
|
||||
offsetY: it.offsetY,
|
||||
})),
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
emojis: user.emojis,
|
||||
onlineStatus: 'active',
|
||||
badgeRoles: [],
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> {
|
||||
return {
|
||||
...toPackedUserLite(user),
|
||||
url: null,
|
||||
uri: null,
|
||||
movedTo: null,
|
||||
alsoKnownAs: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: false,
|
||||
isSuspended: user.isSuspended,
|
||||
description: null,
|
||||
location: null,
|
||||
birthday: null,
|
||||
lang: null,
|
||||
fields: [],
|
||||
verifiedLinks: [],
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
pinnedNoteIds: [],
|
||||
pinnedNotes: [],
|
||||
pinnedPageId: null,
|
||||
pinnedPage: null,
|
||||
publicReactions: true,
|
||||
notesVisibility: 'public',
|
||||
followersVisibility: 'public',
|
||||
followingVisibility: 'public',
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
securityKeys: false,
|
||||
roles: [],
|
||||
memo: null,
|
||||
moderationNote: undefined,
|
||||
isFollowing: false,
|
||||
isFollowed: false,
|
||||
hasPendingFollowRequestFromYou: false,
|
||||
hasPendingFollowRequestToYou: false,
|
||||
isBlocking: false,
|
||||
isBlocked: false,
|
||||
isMuted: false,
|
||||
isRenoteMuted: false,
|
||||
notify: 'none',
|
||||
withReplies: true,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
const dummyUser1 = generateDummyUser();
|
||||
const dummyUser2 = generateDummyUser({
|
||||
id: 'dummy-user-2',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 30),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis),
|
||||
username: 'dummy2',
|
||||
usernameLower: 'dummy2',
|
||||
name: 'DummyUser2',
|
||||
followersCount: 40,
|
||||
followingCount: 50,
|
||||
notesCount: 900,
|
||||
});
|
||||
const dummyUser3 = generateDummyUser({
|
||||
id: 'dummy-user-3',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 15),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis * 2),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis * 2),
|
||||
username: 'dummy3',
|
||||
usernameLower: 'dummy3',
|
||||
name: 'DummyUser3',
|
||||
followersCount: 60,
|
||||
followingCount: 70,
|
||||
notesCount: 15900,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class WebhookTestService {
|
||||
public static NoSuchWebhookError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
private userWebhookService: UserWebhookService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* UserWebhookのテスト送信を行う.
|
||||
* 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
|
||||
*
|
||||
* また、この関数経由で送信されるWebhookは以下の設定を無視する.
|
||||
* - Webhookそのものの有効・無効設定(active)
|
||||
* - 送信対象イベント(on)に関する設定
|
||||
*/
|
||||
@bindThis
|
||||
public async testUserWebhook(
|
||||
params: {
|
||||
webhookId: MiWebhook['id'],
|
||||
type: WebhookEventTypes,
|
||||
override?: Partial<Omit<MiWebhook, 'id'>>,
|
||||
},
|
||||
sender: MiUser | null,
|
||||
) {
|
||||
const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] })
|
||||
.then(it => it.filter(it => it.userId === sender?.id));
|
||||
if (webhooks.length === 0) {
|
||||
throw new WebhookTestService.NoSuchWebhookError();
|
||||
}
|
||||
|
||||
const webhook = webhooks[0];
|
||||
const send = (contents: unknown) => {
|
||||
const merged = {
|
||||
...webhook,
|
||||
...params.override,
|
||||
};
|
||||
|
||||
// テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||
// また、Jobの試行回数も1回だけ.
|
||||
this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 });
|
||||
};
|
||||
|
||||
const dummyNote1 = generateDummyNote({
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
});
|
||||
const dummyReply1 = generateDummyNote({
|
||||
id: 'dummy-reply-1',
|
||||
replyId: dummyNote1.id,
|
||||
reply: dummyNote1,
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
});
|
||||
const dummyRenote1 = generateDummyNote({
|
||||
id: 'dummy-renote-1',
|
||||
renoteId: dummyNote1.id,
|
||||
renote: dummyNote1,
|
||||
userId: dummyUser2.id,
|
||||
user: dummyUser2,
|
||||
text: null,
|
||||
});
|
||||
const dummyMention1 = generateDummyNote({
|
||||
id: 'dummy-mention-1',
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
text: `@${dummyUser2.username} This is a mention to you.`,
|
||||
mentions: [dummyUser2.id],
|
||||
});
|
||||
|
||||
switch (params.type) {
|
||||
case 'note': {
|
||||
send(toPackedNote(dummyNote1));
|
||||
break;
|
||||
}
|
||||
case 'reply': {
|
||||
send(toPackedNote(dummyReply1));
|
||||
break;
|
||||
}
|
||||
case 'renote': {
|
||||
send(toPackedNote(dummyRenote1));
|
||||
break;
|
||||
}
|
||||
case 'mention': {
|
||||
send(toPackedNote(dummyMention1));
|
||||
break;
|
||||
}
|
||||
case 'follow': {
|
||||
send(toPackedUserDetailedNotMe(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'followed': {
|
||||
send(toPackedUserLite(dummyUser2));
|
||||
break;
|
||||
}
|
||||
case 'unfollow': {
|
||||
send(toPackedUserDetailedNotMe(dummyUser3));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemWebhookのテスト送信を行う.
|
||||
* 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
|
||||
*
|
||||
* また、この関数経由で送信されるWebhookは以下の設定を無視する.
|
||||
* - Webhookそのものの有効・無効設定(isActive)
|
||||
* - 送信対象イベント(on)に関する設定
|
||||
*/
|
||||
@bindThis
|
||||
public async testSystemWebhook(
|
||||
params: {
|
||||
webhookId: MiSystemWebhook['id'],
|
||||
type: SystemWebhookEventType,
|
||||
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
|
||||
},
|
||||
) {
|
||||
const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] });
|
||||
if (webhooks.length === 0) {
|
||||
throw new WebhookTestService.NoSuchWebhookError();
|
||||
}
|
||||
|
||||
const webhook = webhooks[0];
|
||||
const send = (contents: unknown) => {
|
||||
const merged = {
|
||||
...webhook,
|
||||
...params.override,
|
||||
};
|
||||
|
||||
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||
// また、Jobの試行回数も1回だけ.
|
||||
this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case 'abuseReport': {
|
||||
send(generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
reporter: dummyUser2,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'abuseReportResolved': {
|
||||
send(generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
reporter: dummyUser2,
|
||||
assigneeId: dummyUser3.id,
|
||||
assignee: dummyUser3,
|
||||
resolved: true,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'userCreated': {
|
||||
send(toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -49,6 +49,7 @@ import type { ApLoggerService } from '../ApLoggerService.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { ApImageService } from './ApImageService.js';
|
||||
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
||||
import unfavorite from '@/server/api/endpoints/channels/unfavorite.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
@ -308,15 +309,16 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application';
|
||||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
const [notesVisibility, followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.notes, resolver),
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
].map((p): Promise<'public' | 'private'> => p
|
||||
.then(isPublic => isPublic ? 'public' : 'private')
|
||||
.catch(err => {
|
||||
if (!(err instanceof StatusError) || err.isRetryable) {
|
||||
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
|
||||
this.logger.error('error occurred while fetching notes/following/followers collection', { stack: err });
|
||||
}
|
||||
return 'private';
|
||||
})
|
||||
@ -397,6 +399,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
description: _description,
|
||||
url,
|
||||
fields,
|
||||
notesVisibility,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
@ -507,15 +510,16 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
const [notesVisibility, followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.notes, resolver),
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
].map((p): Promise<'public' | 'private' | undefined> => p
|
||||
.then(isPublic => isPublic ? 'public' : 'private')
|
||||
.catch(err => {
|
||||
if (!(err instanceof StatusError) || err.isRetryable) {
|
||||
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
|
||||
this.logger.error('error occurred while fetching notes/following/followers collection', { stack: err });
|
||||
// Do not update the visibiility on transient errors.
|
||||
return undefined;
|
||||
}
|
||||
@ -595,6 +599,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
url,
|
||||
fields,
|
||||
description: _description,
|
||||
notesVisibility,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
|
@ -185,6 +185,7 @@ export interface IActor extends IObject {
|
||||
id: string;
|
||||
publicKeyPem: string;
|
||||
};
|
||||
notes?: string | ICollection | IOrderedCollection;
|
||||
followers?: string | ICollection | IOrderedCollection;
|
||||
following?: string | ICollection | IOrderedCollection;
|
||||
featured?: string | IOrderedCollection;
|
||||
|
@ -489,6 +489,10 @@ export class UserEntityService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
|
||||
const notesCount = profile == null ? null :
|
||||
(profile.notesVisibility === 'public') || isMe || iAmModerator ? user.notesCount :
|
||||
(profile.notesVisibility === 'followers') && (relation && relation.isFollowing) ? user.notesCount :
|
||||
null;
|
||||
|
||||
const followingCount = profile == null ? null :
|
||||
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
|
||||
@ -544,7 +548,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
} : undefined) : undefined,
|
||||
followersCount: followersCount ?? 0,
|
||||
followingCount: followingCount ?? 0,
|
||||
notesCount: user.notesCount,
|
||||
notesCount: notesCount ?? 0,
|
||||
emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost),
|
||||
onlineStatus: this.getOnlineStatus(user),
|
||||
// パフォーマンス上の理由でローカルユーザーのみ
|
||||
@ -588,6 +592,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null,
|
||||
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
|
||||
hideActivity: this.isLocalUser(user) ? profile!.hideActivity : false, //
|
||||
notesVisibility: profile!.notesVisibility,
|
||||
followersVisibility: profile!.followersVisibility,
|
||||
followingVisibility: profile!.followingVisibility,
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
|
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js';
|
||||
import { obsoleteNotificationTypes, notesVisibilities, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiPage } from './Page.js';
|
||||
@ -100,6 +100,12 @@ export class MiUserProfile {
|
||||
})
|
||||
public publicReactions: boolean;
|
||||
|
||||
@Column('enum', {
|
||||
enum: notesVisibilities,
|
||||
default: 'public',
|
||||
})
|
||||
public notesVisibility: typeof notesVisibilities[number]
|
||||
|
||||
@Column('enum', {
|
||||
enum: followingVisibilities,
|
||||
default: 'public',
|
||||
|
@ -373,6 +373,11 @@ export const packedUserDetailedNotMeOnlySchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
notesVisibility: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
enum: ['public', 'followers', 'private'],
|
||||
},
|
||||
followingVisibility: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
|
@ -195,6 +195,7 @@ export const paramDef = {
|
||||
receiveAnnouncementEmail: { type: 'boolean' },
|
||||
alwaysMarkNsfw: { type: 'boolean' },
|
||||
autoSensitive: { type: 'boolean' },
|
||||
notesVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
@ -289,6 +290,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (ps.location !== undefined) profileUpdates.location = ps.location;
|
||||
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
|
||||
if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz;
|
||||
if (ps.notesVisibility !== undefined) profileUpdates.notesVisibility = ps.notesVisibility;
|
||||
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
|
||||
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import type { MiMeta, UsersRepository, NotesRepository, UserProfilesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@ -16,6 +16,9 @@ import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
@ -40,6 +43,12 @@ export const meta = {
|
||||
id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b',
|
||||
},
|
||||
|
||||
forbidden: {
|
||||
message: 'Forbidden.',
|
||||
code: 'FORBIDDEN',
|
||||
id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba',
|
||||
},
|
||||
|
||||
bothWithRepliesAndWithFiles: {
|
||||
message: 'Specifying both withReplies and withFiles is not supported',
|
||||
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
||||
@ -62,22 +71,45 @@ export const paramDef = {
|
||||
untilDate: { type: 'integer' },
|
||||
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
|
||||
withFiles: { type: 'boolean', default: false },
|
||||
|
||||
username: { type: 'string' },
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['userId'] },
|
||||
{ required: ['username', 'host'] },
|
||||
],
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
|
||||
@Inject(DI.meta)
|
||||
private serverSettings: MiMeta,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@ -88,6 +120,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
|
||||
|
||||
const user = await this.usersRepository.findOneBy(ps.userId != null
|
||||
? { id: ps.userId }
|
||||
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
|
||||
|
||||
if (user == null) {
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
if (profile.notesVisibility !== 'public' && !await this.roleService.isModerator(me)) {
|
||||
if (profile.notesVisibility === 'private') {
|
||||
if (me == null || (me.id !== user.id)) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
}
|
||||
} else if (profile.notesVisibility === 'followers') {
|
||||
if (me == null) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
} else if (me.id !== user.id) {
|
||||
const isFollowing = await this.notesRepository.exists({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
if (!isFollowing) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// early return if me is blocked by requesting user
|
||||
if (me != null) {
|
||||
const userIdsWhoBlockingMe = await this.cacheService.userBlockedCache.fetch(me.id);
|
||||
|
@ -62,8 +62,8 @@ export class FeedService {
|
||||
id: author.link,
|
||||
title: `${author.name} (@${user.username}@${this.config.host})`,
|
||||
updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined,
|
||||
generator: 'Sharkey',
|
||||
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
|
||||
generator: 'Misskey',
|
||||
description: `${(profile.notesVisibility === 'public') ? user.notesCount : '?'} Notes ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
|
||||
link: author.link,
|
||||
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
feedLinks: {
|
||||
|
@ -49,6 +49,7 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as
|
||||
|
||||
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
|
||||
|
||||
export const notesVisibilities = ['public', 'followers', 'private'] as const;
|
||||
export const followingVisibilities = ['public', 'followers', 'private'] as const;
|
||||
export const followersVisibilities = ['public', 'followers', 'private'] as const;
|
||||
|
||||
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
|
||||
</div>
|
||||
<div :class="$style.status">
|
||||
<div :class="$style.statusItem">
|
||||
<div v-if="isNotesVisibilityForMe(user)" :class="$style.statusItem">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span>
|
||||
</div>
|
||||
<div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem">
|
||||
@ -40,7 +40,7 @@ import number from '@/filters/number.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
|
||||
import { isNotesVisibilityForMe, isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</dl>
|
||||
</div>
|
||||
<div :class="$style.status">
|
||||
<div :class="$style.statusItem">
|
||||
<div v-if="isNotesVisibilityForMe(user)" :class="$style.statusItem">
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
|
||||
<div>{{ number(user.notesCount) }}</div>
|
||||
</div>
|
||||
@ -78,7 +78,7 @@ import number from '@/filters/number.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
|
||||
import { isNotesVisibilityForMe, isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -22,6 +22,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #caption>{{ i18n.ts.hideActivityDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSelect v-model="notesVisibility" @update:modelValue="save()">
|
||||
<template #label>{{ i18n.ts.notesVisibility }}</template>
|
||||
<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
|
||||
<option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
|
||||
<option value="private">{{ i18n.ts._ffVisibility.private }}</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect v-model="followingVisibility" @update:modelValue="save()">
|
||||
<template #label>{{ i18n.ts.followingVisibility }}</template>
|
||||
<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
|
||||
@ -103,6 +110,7 @@ const isExplorable = ref($i.isExplorable);
|
||||
const hideOnlineStatus = ref($i.hideOnlineStatus);
|
||||
const publicReactions = ref($i.publicReactions);
|
||||
const hideActivity = ref($i.hideActivity);
|
||||
const notesVisibility = ref($i.notesVisibility);
|
||||
const followingVisibility = ref($i.followingVisibility);
|
||||
const followersVisibility = ref($i.followersVisibility);
|
||||
|
||||
@ -122,6 +130,7 @@ function save() {
|
||||
hideOnlineStatus: !!hideOnlineStatus.value,
|
||||
publicReactions: !!publicReactions.value,
|
||||
hideActivity: !!hideActivity.value,
|
||||
notesVisibility: notesVisibility.value,
|
||||
followingVisibility: followingVisibility.value,
|
||||
followersVisibility: followersVisibility.value,
|
||||
});
|
||||
|
@ -103,7 +103,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</dl>
|
||||
</div>
|
||||
<div class="status">
|
||||
<MkA :to="userPage(user)">
|
||||
<MkA v-if="isNotesVisibilityForMe(user)" :to="userPage(user, 'notes')">
|
||||
<b>{{ number(user.notesCount) }}</b>
|
||||
<span>{{ i18n.ts.notes }}</span>
|
||||
</MkA>
|
||||
@ -186,7 +186,7 @@ import { $i, iAmModerator } from '@/account.js';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
import { confetti } from '@/scripts/confetti.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
|
||||
import { isNotesVisibilityForMe, isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
|
||||
|
||||
|
@ -6,6 +6,14 @@
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
export function isNotesVisibilityForMe(user: Misskey.entities.UserDetailed): boolean {
|
||||
if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
|
||||
|
||||
if (user.notesVisibility === 'private') return false;
|
||||
if (user.notesVisibility === 'followers' && !user.isFollowing) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
|
||||
if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
|
||||
|
||||
|
@ -2030,6 +2030,9 @@ type FollowingUpdateRequest = operations['following___update']['requestBody']['c
|
||||
// @public (undocumented)
|
||||
type FollowingUpdateResponse = operations['following___update']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
export const notesVisibilities: readonly ["public", "followers", "private"];
|
||||
|
||||
// @public (undocumented)
|
||||
export const followingVisibilities: readonly ["public", "followers", "private"];
|
||||
|
||||
|
@ -3890,6 +3890,8 @@ export type components = {
|
||||
publicReactions: boolean;
|
||||
hideActivity: boolean;
|
||||
/** @enum {string} */
|
||||
notesVisibility: 'public' | 'followers' | 'private';
|
||||
/** @enum {string} */
|
||||
followingVisibility: 'public' | 'followers' | 'private';
|
||||
/** @enum {string} */
|
||||
followersVisibility: 'public' | 'followers' | 'private';
|
||||
|
@ -22,6 +22,8 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as
|
||||
|
||||
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
|
||||
|
||||
export const notesVisibilities = ['public', 'followers', 'private'] as const;
|
||||
|
||||
export const followingVisibilities = ['public', 'followers', 'private'] as const;
|
||||
|
||||
export const followersVisibilities = ['public', 'followers', 'private'] as const;
|
||||
|
@ -19,6 +19,7 @@ export const permissions = consts.permissions;
|
||||
export const notificationTypes = consts.notificationTypes;
|
||||
export const noteVisibilities = consts.noteVisibilities;
|
||||
export const mutedNoteReasons = consts.mutedNoteReasons;
|
||||
export const notesVisibilities = consts.notesVisibilities;
|
||||
export const followingVisibilities = consts.followingVisibilities;
|
||||
export const followersVisibilities = consts.followersVisibilities;
|
||||
export const moderationLogTypes = consts.moderationLogTypes;
|
||||
|
Loading…
Reference in New Issue
Block a user