Merge pull request #40 from lqvp/master

ノート数を隠せるように
This commit is contained in:
ひたりん 2024-10-03 08:14:47 +09:00 committed by hijiki
parent 4299588806
commit bf40bc4fe3
23 changed files with 611 additions and 28 deletions

View File

@ -1,5 +1,6 @@
# DIFFRENCE # DIFFRENCE
<<<<<<< HEAD <<<<<<< HEAD
<<<<<<< HEAD
## 2024.9.0-yami-1.3.1 ## 2024.9.0-yami-1.3.1
## Client ## Client
- フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に - フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に
@ -9,12 +10,17 @@
## Feat ## Feat
- ロールで引用通知の設定を制限出来るように - ロールで引用通知の設定を制限出来るように
=======
>>>>>>> 3c53bbbac6 (Merge pull request #40 from lqvp/master)
## 2024.9.0-yami-1.2.9 ## 2024.9.0-yami-1.2.9
## Feat ## Feat
- ノート数を隠せるように(連合しません) - ノート数を隠せるように(連合しません)
<<<<<<< HEAD
======= =======
>>>>>>> 00cf91cf30 (Merge pull request #39 from lqvp/master) >>>>>>> 00cf91cf30 (Merge pull request #39 from lqvp/master)
=======
>>>>>>> 3c53bbbac6 (Merge pull request #40 from lqvp/master)
## 2024.9.0-yami-1.2.8 ## 2024.9.0-yami-1.2.8
## Feat ## Feat
- Cherry-Pick アクティビティの非公開機能(hideki0403/kakurega.app) - Cherry-Pick アクティビティの非公開機能(hideki0403/kakurega.app)

4
locales/index.d.ts vendored
View File

@ -3788,6 +3788,10 @@ export interface Locale extends ILocale {
* *
*/ */
"unmuteThread": string; "unmuteThread": string;
/**
*
*/
"notesVisibility": string;
/** /**
* *
*/ */

View File

@ -943,6 +943,7 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を
classic: "クラシック" classic: "クラシック"
muteThread: "スレッドをミュート" muteThread: "スレッドをミュート"
unmuteThread: "スレッドのミュートを解除" unmuteThread: "スレッドのミュートを解除"
notesVisibility: "ノート数の公開範囲"
followingVisibility: "フォローの公開範囲" followingVisibility: "フォローの公開範囲"
followersVisibility: "フォロワーの公開範囲" followersVisibility: "フォロワーの公開範囲"
continueThread: "さらにスレッドを見る" continueThread: "さらにスレッドを見る"

View 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"`);
}
}

View 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;
}
}
}
}

View File

@ -49,6 +49,7 @@ import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js'; import type { ApImageService } from './ApImageService.js';
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
import unfavorite from '@/server/api/endpoints/channels/unfavorite.js';
const nameLength = 128; const nameLength = 128;
const summaryLength = 2048; const summaryLength = 2048;
@ -308,15 +309,16 @@ export class ApPersonService implements OnModuleInit {
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; 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.following, resolver),
this.isPublicCollection(person.followers, resolver), this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private'> => p ].map((p): Promise<'public' | 'private'> => p
.then(isPublic => isPublic ? 'public' : 'private') .then(isPublic => isPublic ? 'public' : 'private')
.catch(err => { .catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) { 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'; return 'private';
}) })
@ -397,6 +399,7 @@ export class ApPersonService implements OnModuleInit {
description: _description, description: _description,
url, url,
fields, fields,
notesVisibility,
followingVisibility, followingVisibility,
followersVisibility, followersVisibility,
birthday: bday?.[0] ?? null, birthday: bday?.[0] ?? null,
@ -507,15 +510,16 @@ export class ApPersonService implements OnModuleInit {
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); 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.following, resolver),
this.isPublicCollection(person.followers, resolver), this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private' | undefined> => p ].map((p): Promise<'public' | 'private' | undefined> => p
.then(isPublic => isPublic ? 'public' : 'private') .then(isPublic => isPublic ? 'public' : 'private')
.catch(err => { .catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) { 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. // Do not update the visibiility on transient errors.
return undefined; return undefined;
} }
@ -595,6 +599,7 @@ export class ApPersonService implements OnModuleInit {
url, url,
fields, fields,
description: _description, description: _description,
notesVisibility,
followingVisibility, followingVisibility,
followersVisibility, followersVisibility,
birthday: bday?.[0] ?? null, birthday: bday?.[0] ?? null,

View File

@ -185,6 +185,7 @@ export interface IActor extends IObject {
id: string; id: string;
publicKeyPem: string; publicKeyPem: string;
}; };
notes?: string | ICollection | IOrderedCollection;
followers?: string | ICollection | IOrderedCollection; followers?: string | ICollection | IOrderedCollection;
following?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection;
featured?: string | IOrderedCollection; featured?: string | IOrderedCollection;

View File

@ -489,6 +489,10 @@ export class UserEntityService implements OnModuleInit {
} }
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; 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 : const followingCount = profile == null ? null :
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount : (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
@ -544,7 +548,7 @@ export class UserEntityService implements OnModuleInit {
} : undefined) : undefined, } : undefined) : undefined,
followersCount: followersCount ?? 0, followersCount: followersCount ?? 0,
followingCount: followingCount ?? 0, followingCount: followingCount ?? 0,
notesCount: user.notesCount, notesCount: notesCount ?? 0,
emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost), emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost),
onlineStatus: this.getOnlineStatus(user), onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由でローカルユーザーのみ // パフォーマンス上の理由でローカルユーザーのみ
@ -588,6 +592,7 @@ export class UserEntityService implements OnModuleInit {
pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null, 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 publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
hideActivity: this.isLocalUser(user) ? profile!.hideActivity : false, // hideActivity: this.isLocalUser(user) ? profile!.hideActivity : false, //
notesVisibility: profile!.notesVisibility,
followersVisibility: profile!.followersVisibility, followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility, followingVisibility: profile!.followingVisibility,
twoFactorEnabled: profile!.twoFactorEnabled, twoFactorEnabled: profile!.twoFactorEnabled,

View File

@ -4,7 +4,7 @@
*/ */
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; 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 { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiPage } from './Page.js'; import { MiPage } from './Page.js';
@ -100,6 +100,12 @@ export class MiUserProfile {
}) })
public publicReactions: boolean; public publicReactions: boolean;
@Column('enum', {
enum: notesVisibilities,
default: 'public',
})
public notesVisibility: typeof notesVisibilities[number]
@Column('enum', { @Column('enum', {
enum: followingVisibilities, enum: followingVisibilities,
default: 'public', default: 'public',

View File

@ -373,6 +373,11 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: false, nullable: false, optional: false,
}, },
notesVisibility: {
type: 'string',
nullable: false, optional: false,
enum: ['public', 'followers', 'private'],
},
followingVisibility: { followingVisibility: {
type: 'string', type: 'string',
nullable: false, optional: false, nullable: false, optional: false,

View File

@ -195,6 +195,7 @@ export const paramDef = {
receiveAnnouncementEmail: { type: 'boolean' }, receiveAnnouncementEmail: { type: 'boolean' },
alwaysMarkNsfw: { type: 'boolean' }, alwaysMarkNsfw: { type: 'boolean' },
autoSensitive: { type: 'boolean' }, autoSensitive: { type: 'boolean' },
notesVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, 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.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz; 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.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;

View File

@ -5,7 +5,7 @@
import { Brackets } from 'typeorm'; import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; 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 { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -16,6 +16,9 @@ import { MetaService } from '@/core/MetaService.js';
import { MiLocalUser } from '@/models/User.js'; import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineName } from '@/core/FanoutTimelineService.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'; import { ApiError } from '@/server/api/error.js';
export const meta = { export const meta = {
@ -40,6 +43,12 @@ export const meta = {
id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b', id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b',
}, },
forbidden: {
message: 'Forbidden.',
code: 'FORBIDDEN',
id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba',
},
bothWithRepliesAndWithFiles: { bothWithRepliesAndWithFiles: {
message: 'Specifying both withReplies and withFiles is not supported', message: 'Specifying both withReplies and withFiles is not supported',
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
@ -62,22 +71,45 @@ export const paramDef = {
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
withFiles: { type: 'boolean', default: false }, 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'], required: ['userId'],
} as const; } as const;
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private utilityService: UtilityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private cacheService: CacheService, private cacheService: CacheService,
private idService: IdService, private idService: IdService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private metaService: MetaService, private metaService: MetaService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); 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); 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 // early return if me is blocked by requesting user
if (me != null) { if (me != null) {
const userIdsWhoBlockingMe = await this.cacheService.userBlockedCache.fetch(me.id); const userIdsWhoBlockingMe = await this.cacheService.userBlockedCache.fetch(me.id);

View File

@ -62,8 +62,8 @@ export class FeedService {
id: author.link, id: author.link,
title: `${author.name} (@${user.username}@${this.config.host})`, title: `${author.name} (@${user.username}@${this.config.host})`,
updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined, updated: notes.length !== 0 ? this.idService.parse(notes[0].id).date : undefined,
generator: 'Sharkey', generator: 'Misskey',
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, 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, link: author.link,
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
feedLinks: { feedLinks: {

View File

@ -49,6 +49,7 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; 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 followingVisibilities = ['public', 'followers', 'private'] as const;
export const followersVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const;

View File

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span> <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div> </div>
<div :class="$style.status"> <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> <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span>
</div> </div>
<div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem"> <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 { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.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 { getStaticImageUrl } from '@/scripts/media-proxy.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';

View File

@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</dl> </dl>
</div> </div>
<div :class="$style.status"> <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 :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
<div>{{ number(user.notesCount) }}</div> <div>{{ number(user.notesCount) }}</div>
</div> </div>
@ -78,7 +78,7 @@ import number from '@/filters/number.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { $i } from '@/account.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 { getStaticImageUrl } from '@/scripts/media-proxy.js';
const props = defineProps<{ const props = defineProps<{

View File

@ -22,6 +22,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.hideActivityDescription }}</template> <template #caption>{{ i18n.ts.hideActivityDescription }}</template>
</MkSwitch> </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()"> <MkSelect v-model="followingVisibility" @update:modelValue="save()">
<template #label>{{ i18n.ts.followingVisibility }}</template> <template #label>{{ i18n.ts.followingVisibility }}</template>
<option value="public">{{ i18n.ts._ffVisibility.public }}</option> <option value="public">{{ i18n.ts._ffVisibility.public }}</option>
@ -103,6 +110,7 @@ const isExplorable = ref($i.isExplorable);
const hideOnlineStatus = ref($i.hideOnlineStatus); const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions); const publicReactions = ref($i.publicReactions);
const hideActivity = ref($i.hideActivity); const hideActivity = ref($i.hideActivity);
const notesVisibility = ref($i.notesVisibility);
const followingVisibility = ref($i.followingVisibility); const followingVisibility = ref($i.followingVisibility);
const followersVisibility = ref($i.followersVisibility); const followersVisibility = ref($i.followersVisibility);
@ -122,6 +130,7 @@ function save() {
hideOnlineStatus: !!hideOnlineStatus.value, hideOnlineStatus: !!hideOnlineStatus.value,
publicReactions: !!publicReactions.value, publicReactions: !!publicReactions.value,
hideActivity: !!hideActivity.value, hideActivity: !!hideActivity.value,
notesVisibility: notesVisibility.value,
followingVisibility: followingVisibility.value, followingVisibility: followingVisibility.value,
followersVisibility: followersVisibility.value, followersVisibility: followersVisibility.value,
}); });

View File

@ -103,7 +103,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</dl> </dl>
</div> </div>
<div class="status"> <div class="status">
<MkA :to="userPage(user)"> <MkA v-if="isNotesVisibilityForMe(user)" :to="userPage(user, 'notes')">
<b>{{ number(user.notesCount) }}</b> <b>{{ number(user.notesCount) }}</b>
<span>{{ i18n.ts.notes }}</span> <span>{{ i18n.ts.notes }}</span>
</MkA> </MkA>
@ -186,7 +186,7 @@ import { $i, iAmModerator } from '@/account.js';
import { dateString } from '@/filters/date.js'; import { dateString } from '@/filters/date.js';
import { confetti } from '@/scripts/confetti.js'; import { confetti } from '@/scripts/confetti.js';
import { misskeyApi } from '@/scripts/misskey-api.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 { useRouter } from '@/router/supplier.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js';

View File

@ -6,6 +6,14 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { $i } from '@/account.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 { export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;

View File

@ -2030,6 +2030,9 @@ type FollowingUpdateRequest = operations['following___update']['requestBody']['c
// @public (undocumented) // @public (undocumented)
type FollowingUpdateResponse = operations['following___update']['responses']['200']['content']['application/json']; type FollowingUpdateResponse = operations['following___update']['responses']['200']['content']['application/json'];
// @public (undocumented)
export const notesVisibilities: readonly ["public", "followers", "private"];
// @public (undocumented) // @public (undocumented)
export const followingVisibilities: readonly ["public", "followers", "private"]; export const followingVisibilities: readonly ["public", "followers", "private"];

View File

@ -3890,6 +3890,8 @@ export type components = {
publicReactions: boolean; publicReactions: boolean;
hideActivity: boolean; hideActivity: boolean;
/** @enum {string} */ /** @enum {string} */
notesVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
followingVisibility: 'public' | 'followers' | 'private'; followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */ /** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private'; followersVisibility: 'public' | 'followers' | 'private';

View File

@ -22,6 +22,8 @@ export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; 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 followingVisibilities = ['public', 'followers', 'private'] as const;
export const followersVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const;

View File

@ -19,6 +19,7 @@ export const permissions = consts.permissions;
export const notificationTypes = consts.notificationTypes; export const notificationTypes = consts.notificationTypes;
export const noteVisibilities = consts.noteVisibilities; export const noteVisibilities = consts.noteVisibilities;
export const mutedNoteReasons = consts.mutedNoteReasons; export const mutedNoteReasons = consts.mutedNoteReasons;
export const notesVisibilities = consts.notesVisibilities;
export const followingVisibilities = consts.followingVisibilities; export const followingVisibilities = consts.followingVisibilities;
export const followersVisibilities = consts.followersVisibilities; export const followersVisibilities = consts.followersVisibilities;
export const moderationLogTypes = consts.moderationLogTypes; export const moderationLogTypes = consts.moderationLogTypes;