Merge pull request #39 from lqvp/master

プライバシーの強化
This commit is contained in:
lqvp 2024-10-03 06:47:44 +09:00 committed by hijiki
parent 49dc9e63e7
commit 4299588806
25 changed files with 305 additions and 58 deletions

View File

@ -1,4 +1,5 @@
# DIFFRENCE # DIFFRENCE
<<<<<<< HEAD
## 2024.9.0-yami-1.3.1 ## 2024.9.0-yami-1.3.1
## Client ## Client
- フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に - フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に
@ -12,6 +13,8 @@
## Feat ## Feat
- ノート数を隠せるように(連合しません) - ノート数を隠せるように(連合しません)
=======
>>>>>>> 00cf91cf30 (Merge pull request #39 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)

View File

@ -1344,6 +1344,12 @@ _delivery:
autoSuspendedForNotResponding: "Server is suspended due to no responding" autoSuspendedForNotResponding: "Server is suspended due to no responding"
scheduledNoteDelete: "Time Bomb" scheduledNoteDelete: "Time Bomb"
noteDeletationAt: "This note will be deleted at {time}" noteDeletationAt: "This note will be deleted at {time}"
hideActivity: "Hide Activity"
hideActivityDescription: "This option prevents others from viewing your activity on your profile (Summary/Activity tab). Even with this option enabled, you can still view your activity from the Activity tab on your profile."
hideReactionUsers: "Hide Users Who Reacted"
hideReactionUsersDescription: "This option hides the list of users who reacted when hovering over the reaction and the list of users who reacted in the reactions tab on the note detail page."
hideReactionCount: "Hide Reaction Count"
_bubbleGame: _bubbleGame:
howToPlay: "How to play" howToPlay: "How to play"
@ -2831,3 +2837,8 @@ _contextMenu:
_reactionChecksMuting: _reactionChecksMuting:
title: "Check mutings when get reactions" title: "Check mutings when get reactions"
caption: "Check mutings when get reactions, but cache does not work and may increase traffic" caption: "Check mutings when get reactions, but cache does not work and may increase traffic"
_hideReactionCount:
none: "Do not hide"
self: "Only my notes"
others: "Only notes of others"
all: "All notes"

38
locales/index.d.ts vendored
View File

@ -5401,6 +5401,26 @@ export interface Locale extends ILocale {
"autoSuspendedForNotResponding": string; "autoSuspendedForNotResponding": string;
}; };
}; };
/**
*
*/
"hideActivity": string;
/**
* (/)
*/
"hideActivityDescription": string;
/**
*
*/
"hideReactionUsers": string;
/**
*
*/
"hideReactionUsersDescription": string;
/**
*
*/
"hideReactionCount": string;
"_bubbleGame": { "_bubbleGame": {
/** /**
* *
@ -10937,6 +10957,24 @@ export interface Locale extends ILocale {
*/ */
"caption": string; "caption": string;
}; };
"_hideReactionCount": {
/**
*
*/
"none": string;
/**
*
*/
"self": string;
/**
*
*/
"others": string;
/**
*
*/
"all": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -1348,6 +1348,11 @@ defaultScheduledNoteDeleteTime: "ノートの自己消滅の初期値"
scheduledNoteDeleteEnabled: "ノートの自己消滅が有効になっています" scheduledNoteDeleteEnabled: "ノートの自己消滅が有効になっています"
cannotScheduleLaterThanOneYear: "1年以上先の日時を指定することはできません" cannotScheduleLaterThanOneYear: "1年以上先の日時を指定することはできません"
defaultScheduledNoteDelete: "デフォルトでノートが自己消滅するように" defaultScheduledNoteDelete: "デフォルトでノートが自己消滅するように"
hideActivity: "アクティビティを非公開にする"
hideActivityDescription: "自分のプロフィールのアクティビティ (概要/アクティビティタブ) を他人が見れないようにします。このオプションを有効にしても、自分であればプロフィールのアクティビティタブから引き続き閲覧できます。"
hideReactionUsers: "誰がリアクションをしたのかを非表示にする"
hideReactionUsersDescription: "リアクションをホバーした際のユーザー一覧と、ノート詳細ページのリアクションタブにあるリアクションをしたユーザー一覧を非表示にします"
hideReactionCount: "リアクション数の非表示"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"
@ -2911,3 +2916,8 @@ _contextMenu:
_reactionChecksMuting: _reactionChecksMuting:
title: "リアクションでミュートを考慮する" title: "リアクションでミュートを考慮する"
caption: "リアクションがミュートを考慮しますが、キャッシュが効かず通信量が増えることがあります。" caption: "リアクションがミュートを考慮しますが、キャッシュが効かず通信量が増えることがあります。"
_hideReactionCount:
none: "非表示にしない"
self: "自分のノートのみ"
others: "自分以外のノートのみ"
all: "全てのノート"

View File

@ -0,0 +1,11 @@
export class FeatHideActivity1710146785085 {
name = 'FeatHideActivity1710146785085'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "hideActivity" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "hideActivity"`);
}
}

View File

@ -587,6 +587,7 @@ export class UserEntityService implements OnModuleInit {
pinnedPageId: profile!.pinnedPageId, pinnedPageId: profile!.pinnedPageId,
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, //
followersVisibility: profile!.followersVisibility, followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility, followingVisibility: profile!.followingVisibility,
twoFactorEnabled: profile!.twoFactorEnabled, twoFactorEnabled: profile!.twoFactorEnabled,
@ -627,6 +628,7 @@ export class UserEntityService implements OnModuleInit {
isDeleted: user.isDeleted, isDeleted: user.isDeleted,
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus, hideOnlineStatus: user.hideOnlineStatus,
enableGTL: profile!.enableGTL,
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
where: { userId: user.id, isSpecified: true }, where: { userId: user.id, isSpecified: true },
take: 1, take: 1,

View File

@ -112,6 +112,11 @@ export class MiUserProfile {
}) })
public followersVisibility: typeof followersVisibilities[number]; public followersVisibility: typeof followersVisibilities[number];
@Column('boolean', {
default: false,
})
public hideActivity: boolean;
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
}) })

View File

@ -369,6 +369,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: false, nullable: false, optional: false,
}, },
hideActivity: {
type: 'boolean',
nullable: false, optional: false,
},
followingVisibility: { followingVisibility: {
type: 'string', type: 'string',
nullable: false, optional: false, nullable: false, optional: false,

View File

@ -3,11 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { getJsonSchema } from '@/core/chart/core.js'; import { getJsonSchema } from '@/core/chart/core.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
import { schema } from '@/core/chart/charts/entities/per-user-drive.js'; import { schema } from '@/core/chart/charts/entities/per-user-drive.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ['charts', 'drive', 'users'], tags: ['charts', 'drive', 'users'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true, allowGet: true,
cacheSec: 60 * 60, cacheSec: 60 * 60,
errors: {
activityNotPublic: {
message: 'Activity of the user is not public.',
code: 'ACTIVITY_NOT_PUBLIC',
id: '28e59b25-7eaf-4ff4-bac5-251fd7d8449b',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@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.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserDriveChart: PerUserDriveChart, private perUserDriveChart: PerUserDriveChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see activity of all users
if (!iAmModerator) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
if ((me == null || me.id !== ps.userId) && profile.hideActivity) {
throw new ApiError(meta.errors.activityNotPublic);
}
}
return await this.perUserDriveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); return await this.perUserDriveChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
}); });
} }

View File

@ -3,11 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { getJsonSchema } from '@/core/chart/core.js'; import { getJsonSchema } from '@/core/chart/core.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { schema } from '@/core/chart/charts/entities/per-user-following.js'; import { schema } from '@/core/chart/charts/entities/per-user-following.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'following'], tags: ['charts', 'users', 'following'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true, allowGet: true,
cacheSec: 60 * 60, cacheSec: 60 * 60,
errors: {
activityNotPublic: {
message: 'Activity of the user is not public.',
code: 'ACTIVITY_NOT_PUBLIC',
id: '28e59b25-7eaf-4ff4-bac5-251fd7d8449b',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@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.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see activity of all users
if (!iAmModerator) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
if ((me == null || me.id !== ps.userId) && profile.hideActivity) {
throw new ApiError(meta.errors.activityNotPublic);
}
}
return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
}); });
} }

View File

@ -3,11 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { getJsonSchema } from '@/core/chart/core.js'; import { getJsonSchema } from '@/core/chart/core.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
import { schema } from '@/core/chart/charts/entities/per-user-notes.js'; import { schema } from '@/core/chart/charts/entities/per-user-notes.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'notes'], tags: ['charts', 'users', 'notes'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true, allowGet: true,
cacheSec: 60 * 60, cacheSec: 60 * 60,
errors: {
activityNotPublic: {
message: 'Activity of the user is not public.',
code: 'ACTIVITY_NOT_PUBLIC',
id: '28e59b25-7eaf-4ff4-bac5-251fd7d8449b',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@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.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserNotesChart: PerUserNotesChart, private perUserNotesChart: PerUserNotesChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see activity of all users
if (!iAmModerator) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
if ((me == null || me.id !== ps.userId) && profile.hideActivity) {
throw new ApiError(meta.errors.activityNotPublic);
}
}
return await this.perUserNotesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); return await this.perUserNotesChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
}); });
} }

View File

@ -3,11 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { getJsonSchema } from '@/core/chart/core.js'; import { getJsonSchema } from '@/core/chart/core.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; import PerUserPvChart from '@/core/chart/charts/per-user-pv.js';
import { schema } from '@/core/chart/charts/entities/per-user-pv.js'; import { schema } from '@/core/chart/charts/entities/per-user-pv.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ['charts', 'users'], tags: ['charts', 'users'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true, allowGet: true,
cacheSec: 60 * 60, cacheSec: 60 * 60,
errors: {
activityNotPublic: {
message: 'Activity of the user is not public.',
code: 'ACTIVITY_NOT_PUBLIC',
id: '28e59b25-7eaf-4ff4-bac5-251fd7d8449b',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@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.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserPvChart: PerUserPvChart, private perUserPvChart: PerUserPvChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see activity of all users
if (!iAmModerator) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
if ((me == null || me.id !== ps.userId) && profile.hideActivity) {
throw new ApiError(meta.errors.activityNotPublic);
}
}
return await this.perUserPvChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); return await this.perUserPvChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
}); });
} }

View File

@ -3,11 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { getJsonSchema } from '@/core/chart/core.js'; import { getJsonSchema } from '@/core/chart/core.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
import { schema } from '@/core/chart/charts/entities/per-user-reactions.js'; import { schema } from '@/core/chart/charts/entities/per-user-reactions.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'reactions'], tags: ['charts', 'users', 'reactions'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true, allowGet: true,
cacheSec: 60 * 60, cacheSec: 60 * 60,
errors: {
activityNotPublic: {
message: 'Activity of the user is not public.',
code: 'ACTIVITY_NOT_PUBLIC',
id: '28e59b25-7eaf-4ff4-bac5-251fd7d8449b',
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@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.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserReactionsChart: PerUserReactionsChart, private perUserReactionsChart: PerUserReactionsChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see activity of all users
if (!iAmModerator) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
if ((me == null || me.id !== ps.userId) && profile.hideActivity) {
throw new ApiError(meta.errors.activityNotPublic);
}
}
return await this.perUserReactionsChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); return await this.perUserReactionsChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
}); });
} }

View File

@ -181,6 +181,7 @@ export const paramDef = {
isExplorable: { type: 'boolean' }, isExplorable: { type: 'boolean' },
hideOnlineStatus: { type: 'boolean' }, hideOnlineStatus: { type: 'boolean' },
publicReactions: { type: 'boolean' }, publicReactions: { type: 'boolean' },
hideActivity: { type: 'boolean' },
carefulBot: { type: 'boolean' }, carefulBot: { type: 'boolean' },
autoAcceptFollowed: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' },
autoRejectFollowRequest: { type: 'boolean' }, autoRejectFollowRequest: { type: 'boolean' },
@ -335,6 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions;
if (typeof ps.noindex === 'boolean') updates.noindex = ps.noindex; if (typeof ps.noindex === 'boolean') updates.noindex = ps.noindex;
if (typeof ps.hideActivity === 'boolean') profileUpdates.hideActivity = ps.hideActivity;
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;

View File

@ -11,7 +11,6 @@ import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -82,7 +81,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private utilityService: UtilityService, private utilityService: UtilityService,
private followingEntityService: FollowingEntityService, private followingEntityService: FollowingEntityService,
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null const user = await this.usersRepository.findOneBy(ps.userId != null
@ -95,24 +93,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.followersVisibility !== 'public' && !await this.roleService.isModerator(me)) { if (profile.followersVisibility === 'private') {
if (profile.followersVisibility === 'private') { if (me == null || (me.id !== user.id)) {
if (me == null || (me.id !== user.id)) { throw new ApiError(meta.errors.forbidden);
throw new ApiError(meta.errors.forbidden); }
} } else if (profile.followersVisibility === 'followers') {
} else if (profile.followersVisibility === 'followers') { if (me == null) {
if (me == null) { throw new ApiError(meta.errors.forbidden);
throw new ApiError(meta.errors.forbidden); } else if (me.id !== user.id) {
} else if (me.id !== user.id) { const isFollowing = await this.followingsRepository.exists({
const isFollowing = await this.followingsRepository.exists({ where: {
where: { followeeId: user.id,
followeeId: user.id, followerId: me.id,
followerId: me.id, },
}, });
}); if (!isFollowing) {
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}
} }
} }
} }

View File

@ -12,7 +12,6 @@ import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -91,7 +90,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private utilityService: UtilityService, private utilityService: UtilityService,
private followingEntityService: FollowingEntityService, private followingEntityService: FollowingEntityService,
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null const user = await this.usersRepository.findOneBy(ps.userId != null
@ -104,24 +102,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.followingVisibility !== 'public' && !await this.roleService.isModerator(me)) { if (profile.followingVisibility === 'private') {
if (profile.followingVisibility === 'private') { if (me == null || (me.id !== user.id)) {
if (me == null || (me.id !== user.id)) { throw new ApiError(meta.errors.forbidden);
throw new ApiError(meta.errors.forbidden); }
} } else if (profile.followingVisibility === 'followers') {
} else if (profile.followingVisibility === 'followers') { if (me == null) {
if (me == null) { throw new ApiError(meta.errors.forbidden);
throw new ApiError(meta.errors.forbidden); } else if (me.id !== user.id) {
} else if (me.id !== user.id) { const isFollowing = await this.followingsRepository.exists({
const isFollowing = await this.followingsRepository.exists({ where: {
where: { followeeId: user.id,
followeeId: user.id, followerId: me.id,
followerId: me.id, },
}, });
}); if (!isFollowing) {
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}
} }
} }
} }

View File

@ -196,9 +196,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs"> <div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> <button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = defaultStore.state.hideReactionUsers ? null : reaction">
<MkReactionIcon :reaction="reaction"/> <MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span> <span v-if="!hideReactionCount" style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
</button> </button>
</div> </div>
<MkButton v-if="reactionTabType" :class="$style.reactionMuteButton" @click="reactionMuteToggle(reactionTabTypeTrimLocal)"> <MkButton v-if="reactionTabType" :class="$style.reactionMuteButton" @click="reactionMuteToggle(reactionTabTypeTrimLocal)">
@ -352,6 +352,15 @@ if ($i) {
} }
let renoting = false; let renoting = false;
const hideReactionCount = computed(() => {
switch (defaultStore.state.hideReactionCount) {
case 'none': return false;
case 'all': return true;
case 'self': return props.note.userId === $i?.id;
case 'others': return props.note.userId !== $i?.id;
default: return false;
}
});
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', type: 'lookup',

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :noStyle="true"/> <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :noStyle="true"/>
<div :class="$style.reactionName">{{ getReactionName(reaction) }}</div> <div :class="$style.reactionName">{{ getReactionName(reaction) }}</div>
</div> </div>
<div :class="$style.users"> <div v-if="users.length" :class="$style.users">
<div v-for="u in users" :key="u.id" :class="$style.user"> <div v-for="u in users" :key="u.id" :class="$style.user">
<MkAvatar :class="$style.avatar" :user="u"/> <MkAvatar :class="$style.avatar" :user="u"/>
<MkUserName :user="u" :nowrap="true"/> <MkUserName :user="u" :nowrap="true"/>
@ -57,9 +57,7 @@ function getReactionName(reaction: string): string {
.reaction { .reaction {
max-width: 100px; max-width: 100px;
padding-right: 10px;
text-align: center; text-align: center;
border-right: solid 0.5px var(--divider);
} }
.reactionIcon { .reactionIcon {
@ -80,6 +78,8 @@ function getReactionName(reaction: string): string {
margin: -4px 14px 0 10px; margin: -4px 14px 0 10px;
font-size: 0.95em; font-size: 0.95em;
text-align: left; text-align: left;
padding-left: 10px;
border-left: solid 0.5px var(--divider);
} }
.user { .user {

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@contextmenu.prevent.stop="menu" @contextmenu.prevent.stop="menu"
> >
<MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()" @click.stop/> <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()" @click.stop/>
<span :class="$style.count">{{ count }}</span> <span v-if="!hideReactionCount" :class="$style.count">{{ count }}</span>
</button> </button>
</template> </template>
@ -56,11 +56,29 @@ const buttonEl = shallowRef<HTMLElement>();
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
function getReactionName(reaction: string, formated = false) {
const r = reaction.replaceAll(':', '').replace(/@.*/, '');
return formated ? `:${r}:` : r;
}
const isLocal = computed(() => !props.reaction.match(/@\w/));
const isAvailable = computed(() => isLocal.value ? true : customEmojisMap.has(getReactionName(props.reaction)));
const canToggle = computed(() => { const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
}); });
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
const hideReactionCount = computed(() => {
switch (defaultStore.state.hideReactionCount) {
case 'none': return false;
case 'all': return true;
case 'self': return props.note.userId === $i?.id;
case 'others': return props.note.userId !== $i?.id;
default: return false;
}
});
async function toggleReaction() { async function toggleReaction() {
if (!canToggle.value) return; if (!canToggle.value) return;
@ -150,12 +168,12 @@ if (!mock) {
useTooltip(buttonEl, async (showing) => { useTooltip(buttonEl, async (showing) => {
const useGet = !reactionChecksMuting.value; const useGet = !reactionChecksMuting.value;
const apiCall = useGet ? misskeyApiGet : misskeyApi; const apiCall = useGet ? misskeyApiGet : misskeyApi;
const reactions = await apiCall('notes/reactions', { const reactions = !defaultStore.state.hideReactionUsers ? await apiCall('notes/reactions', {
noteId: props.note.id, noteId: props.note.id,
type: props.reaction, type: props.reaction,
limit: 10, limit: 10,
_cacheKey_: props.count, _cacheKey_: props.count,
}); }) : [];
const users = reactions.map(x => x.user); const users = reactions.map(x => x.user);
const count = users.length; const count = users.length;

View File

@ -204,7 +204,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs"> <div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> <button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = defaultStore.state.hideReactionUsers ? null : reaction">
<MkReactionIcon :reaction="reaction"/> <MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span> <span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
</button> </button>

View File

@ -103,6 +103,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="misskey"><i class="sk-icons sk-misskey sk-icons-lg" style="top: 2px;position: relative;"></i> Misskey</option> <option value="misskey"><i class="sk-icons sk-misskey sk-icons-lg" style="top: 2px;position: relative;"></i> Misskey</option>
</MkRadios> </MkRadios>
<MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch> <MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch>
<MkSwitch v-model="hideReactionUsers">
<template #caption>{{ i18n.ts.hideReactionUsersDescription }}</template>
{{ i18n.ts.hideReactionUsers }}
<span class="_beta">{{ i18n.ts.originalFeature }}</span>
</MkSwitch>
<MkSelect v-model="hideReactionCount">
<template #label>{{ i18n.ts.hideReactionCount }}<span class="_beta">{{ i18n.ts.originalFeature }}</span></template>
<option value="none">{{ i18n.ts._hideReactionCount.none }}</option>
<option value="self">{{ i18n.ts._hideReactionCount.self }}</option>
<option value="others">{{ i18n.ts._hideReactionCount.others }}</option>
<option value="all">{{ i18n.ts._hideReactionCount.all }}</option>
</MkSelect>
</div> </div>
<MkSelect v-model="instanceTicker"> <MkSelect v-model="instanceTicker">
@ -382,6 +394,8 @@ const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNot
const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter')); const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter'));
const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize')); const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize'));
const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction')); const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction'));
const hideReactionUsers = computed(defaultStore.makeGetterSetter('hideReactionUsers'));
const hideReactionCount = computed(defaultStore.makeGetterSetter('hideReactionCount'));
const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
const collapseNotesRepliedTo = computed(defaultStore.makeGetterSetter('collapseNotesRepliedTo')); const collapseNotesRepliedTo = computed(defaultStore.makeGetterSetter('collapseNotesRepliedTo'));
const clickToOpen = computed(defaultStore.makeGetterSetter('clickToOpen')); const clickToOpen = computed(defaultStore.makeGetterSetter('clickToOpen'));
@ -494,6 +508,7 @@ watch([
showGapBetweenNotesInTimeline, showGapBetweenNotesInTimeline,
instanceTicker, instanceTicker,
instanceIcon, instanceIcon,
hideReactionCount,
overridedDeviceKind, overridedDeviceKind,
mediaListWithOneImageAppearance, mediaListWithOneImageAppearance,
reactionsDisplaySize, reactionsDisplaySize,

View File

@ -17,6 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="hideActivity" @update:modelValue="save()">
{{ i18n.ts.hideActivity }}<span class="_beta">{{ i18n.ts.originalFeature }}</span>
<template #caption>{{ i18n.ts.hideActivityDescription }}</template>
</MkSwitch>
<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>
@ -97,6 +102,7 @@ const noindex = ref($i.noindex);
const isExplorable = ref($i.isExplorable); 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 followingVisibility = ref($i.followingVisibility); const followingVisibility = ref($i.followingVisibility);
const followersVisibility = ref($i.followersVisibility); const followersVisibility = ref($i.followersVisibility);
@ -115,6 +121,7 @@ function save() {
isExplorable: !!isExplorable.value, isExplorable: !!isExplorable.value,
hideOnlineStatus: !!hideOnlineStatus.value, hideOnlineStatus: !!hideOnlineStatus.value,
publicReactions: !!publicReactions.value, publicReactions: !!publicReactions.value,
hideActivity: !!hideActivity.value,
followingVisibility: followingVisibility.value, followingVisibility: followingVisibility.value,
followersVisibility: followersVisibility.value, followersVisibility: followersVisibility.value,
}); });

View File

@ -156,7 +156,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<XFiles :key="user.id" :user="user"/> <XFiles :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/> <XActivity v-if="!user.hideActivity" :key="user.id" :user="user"/>
<XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/> <XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/>
</div> </div>
</div> </div>

View File

@ -88,11 +88,11 @@ const headerTabs = computed(() => user.value ? [{
key: 'notes', key: 'notes',
title: i18n.ts.notes, title: i18n.ts.notes,
icon: 'ti ti-pencil', icon: 'ti ti-pencil',
}, { }, ...($i && ($i.id === user.value.id || $i.isAdmin || $i.isModerator)) || !user.value.hideActivity ? [{
key: 'activity', key: 'activity',
title: i18n.ts.activity, title: i18n.ts.activity,
icon: 'ti ti-chart-line', icon: 'ti ti-chart-line',
}, ...(user.value.host == null ? [{ }] : [], ...(user.value.host == null ? [{
key: 'achievements', key: 'achievements',
title: i18n.ts.achievements, title: i18n.ts.achievements,
icon: 'ti ti-medal', icon: 'ti ti-medal',

View File

@ -3888,6 +3888,7 @@ export type components = {
pinnedPageId: string | null; pinnedPageId: string | null;
pinnedPage: components['schemas']['Page'] | null; pinnedPage: components['schemas']['Page'] | null;
publicReactions: boolean; publicReactions: boolean;
hideActivity: boolean;
/** @enum {string} */ /** @enum {string} */
followingVisibility: 'public' | 'followers' | 'private'; followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */ /** @enum {string} */
@ -20239,6 +20240,7 @@ export type operations = {
isExplorable?: boolean; isExplorable?: boolean;
hideOnlineStatus?: boolean; hideOnlineStatus?: boolean;
publicReactions?: boolean; publicReactions?: boolean;
hideActivity?: boolean;
carefulBot?: boolean; carefulBot?: boolean;
autoAcceptFollowed?: boolean; autoAcceptFollowed?: boolean;
noCrawle?: boolean; noCrawle?: boolean;