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
<<<<<<< HEAD
## 2024.9.0-yami-1.3.1
## Client
- フォロー/フォロワー/アナウンス/みつける/Play/ギャラリー/チャンネル/TL/ユーザー/ノートのページをログイン必須に
@ -12,6 +13,8 @@
## Feat
- ノート数を隠せるように(連合しません)
=======
>>>>>>> 00cf91cf30 (Merge pull request #39 from lqvp/master)
## 2024.9.0-yami-1.2.8
## Feat
- Cherry-Pick アクティビティの非公開機能(hideki0403/kakurega.app)

View File

@ -1344,6 +1344,12 @@ _delivery:
autoSuspendedForNotResponding: "Server is suspended due to no responding"
scheduledNoteDelete: "Time Bomb"
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:
howToPlay: "How to play"
@ -2831,3 +2837,8 @@ _contextMenu:
_reactionChecksMuting:
title: "Check mutings when get reactions"
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;
};
};
/**
*
*/
"hideActivity": string;
/**
* (/)
*/
"hideActivityDescription": string;
/**
*
*/
"hideReactionUsers": string;
/**
*
*/
"hideReactionUsersDescription": string;
/**
*
*/
"hideReactionCount": string;
"_bubbleGame": {
/**
*
@ -10937,6 +10957,24 @@ export interface Locale extends ILocale {
*/
"caption": string;
};
"_hideReactionCount": {
/**
*
*/
"none": string;
/**
*
*/
"self": string;
/**
*
*/
"others": string;
/**
*
*/
"all": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

@ -1348,6 +1348,11 @@ defaultScheduledNoteDeleteTime: "ノートの自己消滅の初期値"
scheduledNoteDeleteEnabled: "ノートの自己消滅が有効になっています"
cannotScheduleLaterThanOneYear: "1年以上先の日時を指定することはできません"
defaultScheduledNoteDelete: "デフォルトでノートが自己消滅するように"
hideActivity: "アクティビティを非公開にする"
hideActivityDescription: "自分のプロフィールのアクティビティ (概要/アクティビティタブ) を他人が見れないようにします。このオプションを有効にしても、自分であればプロフィールのアクティビティタブから引き続き閲覧できます。"
hideReactionUsers: "誰がリアクションをしたのかを非表示にする"
hideReactionUsersDescription: "リアクションをホバーした際のユーザー一覧と、ノート詳細ページのリアクションタブにあるリアクションをしたユーザー一覧を非表示にします"
hideReactionCount: "リアクション数の非表示"
_bubbleGame:
howToPlay: "遊び方"
@ -2911,3 +2916,8 @@ _contextMenu:
_reactionChecksMuting:
title: "リアクションでミュートを考慮する"
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,
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, //
followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility,
twoFactorEnabled: profile!.twoFactorEnabled,
@ -627,6 +628,7 @@ export class UserEntityService implements OnModuleInit {
isDeleted: user.isDeleted,
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
enableGTL: profile!.enableGTL,
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
where: { userId: user.id, isSpecified: true },
take: 1,

View File

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

View File

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

View File

@ -3,11 +3,15 @@
* 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 { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserDriveChart from '@/core/chart/charts/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 = {
tags: ['charts', 'drive', 'users'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true,
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;
export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserDriveChart: PerUserDriveChart,
) {
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);
});
}

View File

@ -3,11 +3,15 @@
* 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 { getJsonSchema } from '@/core/chart/core.js';
import PerUserFollowingChart from '@/core/chart/charts/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 = {
tags: ['charts', 'users', 'following'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true,
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;
export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserFollowingChart: PerUserFollowingChart,
) {
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);
});
}

View File

@ -3,11 +3,15 @@
* 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 { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserNotesChart from '@/core/chart/charts/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 = {
tags: ['charts', 'users', 'notes'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true,
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;
export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserNotesChart: PerUserNotesChart,
) {
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);
});
}

View File

@ -3,11 +3,15 @@
* 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 { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserPvChart from '@/core/chart/charts/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 = {
tags: ['charts', 'users'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true,
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;
export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserPvChart: PerUserPvChart,
) {
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);
});
}

View File

@ -3,11 +3,15 @@
* 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 { Endpoint } from '@/server/api/endpoint-base.js';
import PerUserReactionsChart from '@/core/chart/charts/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 = {
tags: ['charts', 'users', 'reactions'],
@ -16,6 +20,14 @@ export const meta = {
allowGet: true,
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;
export const paramDef = {
@ -32,9 +44,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private perUserReactionsChart: PerUserReactionsChart,
) {
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);
});
}

View File

@ -181,6 +181,7 @@ export const paramDef = {
isExplorable: { type: 'boolean' },
hideOnlineStatus: { type: 'boolean' },
publicReactions: { type: 'boolean' },
hideActivity: { type: 'boolean' },
carefulBot: { type: 'boolean' },
autoAcceptFollowed: { 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.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions;
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.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
@ -401,7 +403,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
updates.backgroundUrl = null;
updates.backgroundBlurhash = null;
}
if (ps.avatarDecorations) {
policies ??= await this.roleService.getUserPolicies(user.id);
const decorations = await this.avatarDecorationService.getAll(true);

View File

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

View File

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

View File

@ -196,9 +196,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<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"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
<span v-if="!hideReactionCount" style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
</button>
</div>
<MkButton v-if="reactionTabType" :class="$style.reactionMuteButton" @click="reactionMuteToggle(reactionTabTypeTrimLocal)">
@ -352,6 +352,15 @@ if ($i) {
}
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>(() => ({
type: 'lookup',

View File

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

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@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/>
<span :class="$style.count">{{ count }}</span>
<span v-if="!hideReactionCount" :class="$style.count">{{ count }}</span>
</button>
</template>
@ -56,11 +56,29 @@ const buttonEl = shallowRef<HTMLElement>();
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
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(() => {
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 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() {
if (!canToggle.value) return;
@ -150,12 +168,12 @@ if (!mock) {
useTooltip(buttonEl, async (showing) => {
const useGet = !reactionChecksMuting.value;
const apiCall = useGet ? misskeyApiGet : misskeyApi;
const reactions = await apiCall('notes/reactions', {
const reactions = !defaultStore.state.hideReactionUsers ? await apiCall('notes/reactions', {
noteId: props.note.id,
type: props.reaction,
limit: 10,
_cacheKey_: props.count,
});
}) : [];
const users = reactions.map(x => x.user);
const count = users.length;

View File

@ -204,7 +204,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<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"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
</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>
</MkRadios>
<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>
<MkSelect v-model="instanceTicker">
@ -382,6 +394,8 @@ const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNot
const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter'));
const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize'));
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 collapseNotesRepliedTo = computed(defaultStore.makeGetterSetter('collapseNotesRepliedTo'));
const clickToOpen = computed(defaultStore.makeGetterSetter('clickToOpen'));
@ -494,6 +508,7 @@ watch([
showGapBetweenNotesInTimeline,
instanceTicker,
instanceIcon,
hideReactionCount,
overridedDeviceKind,
mediaListWithOneImageAppearance,
reactionsDisplaySize,

View File

@ -17,6 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template>
</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()">
<template #label>{{ i18n.ts.followingVisibility }}</template>
<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
@ -97,6 +102,7 @@ const noindex = ref($i.noindex);
const isExplorable = ref($i.isExplorable);
const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions);
const hideActivity = ref($i.hideActivity);
const followingVisibility = ref($i.followingVisibility);
const followersVisibility = ref($i.followersVisibility);
@ -115,6 +121,7 @@ function save() {
isExplorable: !!isExplorable.value,
hideOnlineStatus: !!hideOnlineStatus.value,
publicReactions: !!publicReactions.value,
hideActivity: !!hideActivity.value,
followingVisibility: followingVisibility.value,
followersVisibility: followersVisibility.value,
});

View File

@ -156,7 +156,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<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"/>
</div>
</div>

View File

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

View File

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