merge: Expand Mandatory CW feature and fixup block/mute/silence features (resolves #809, #910, #912, #943, #1064, #1142, and #1186) (!1148)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1148

Closes #809, #910, #912, #943, #1064, #1142, and #1186

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Marie 2025-09-25 20:05:46 +02:00
commit 741e612508
125 changed files with 3195 additions and 1338 deletions

View file

@ -1,6 +1,18 @@
# Upgrade Notes
## 2025.X.X
## 2025.5.2
### Mark instance as NSFW
The "Mark instance as NSFW" has been removed in favor of the new "mandatory CW" / "force CW" system.
Moderators can now apply any Content Warning of their choice to all notes from an instance by populating the "Force content warning" field on that instance's info page.
The new Content Warning applies immediately, is retroactive, and does not federate or "infect" replies in a thread.
The upgrade will automatically set a content warning of "NSFW" for instances that were formerly marked as NSFW, which displays as `[instance name] is flagged: "NSFW"` to users.
The `notes` table is also cleaned up to remove any leftover "Instance is marked as NSFW" content warnings from posts.
Staff can then remove or modify the new CW as usual.
## 2025.2.2
### Authorized Fetch
@ -13,6 +25,8 @@ Do not remove it before migration, or else the setting will reset to default (di
### Hellspawns
**Note: this workaround is no longer needed on Sharkey version 2025.5.2 and later, as "Mark instance as NSFW" has been completely rewritten.**
Sharkey versions before 2024.10 suffered from a bug in the "Mark instance as NSFW" feature.
When a user from such an instance boosted a note, the boost would be converted to a hellspawn (pure renote with Content Warning).
Hellspawns are buggy and do not properly federate, so it may be desirable to correct any that already exist in the database.

54
locales/index.d.ts vendored
View file

@ -9216,6 +9216,14 @@ export interface Locale extends ILocale {
* Apply mandatory CW on users
*/
"write:admin:cw-user": string;
/**
* Apply mandatory CW on notes
*/
"write:admin:cw-note": string;
/**
* Apply mandatory CW on instances
*/
"write:admin:cw-instance": string;
/**
* Silence users
*/
@ -10953,13 +10961,13 @@ export interface Locale extends ILocale {
*/
"setMandatoryCW": string;
/**
* Set remote instance as NSFW
* Set content warning for note
*/
"setRemoteInstanceNSFW": string;
"setMandatoryCWForNote": string;
/**
* Unset remote instance as NSFW
* Set content warning for instance
*/
"unsetRemoteInstanceNSFW": string;
"setMandatoryCWForInstance": string;
/**
* Rejected reports from remote instance
*/
@ -12088,6 +12096,26 @@ export interface Locale extends ILocale {
* {name} said something in a muted thread
*/
"userSaysSomethingInMutedThread": ParameterizedString<"name">;
/**
* {name} has been silenced by {host} staff
*/
"silencedUserSaysSomething": ParameterizedString<"name" | "host">;
/**
* {name} has been silenced by {host} staff
*/
"silencedInstanceSaysSomething": ParameterizedString<"name" | "host">;
/**
* {name} is flagged: "{cw}"
*/
"userIsFlaggedAs": ParameterizedString<"name" | "cw">;
/**
* Note is flagged: "{cw}"
*/
"noteIsFlaggedAs": ParameterizedString<"cw">;
/**
* {name} is flagged: "{cw}"
*/
"instanceIsFlaggedAs": ParameterizedString<"name" | "cw">;
/**
* Mark all media from user as NSFW
*/
@ -13038,9 +13066,25 @@ export interface Locale extends ILocale {
*/
"mandatoryCW": string;
/**
* Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end.
* Applies a content warning to all posts created by this user. The forced warnings will appear like a word mute to distinguish them from the author's own content warnings.
*/
"mandatoryCWDescription": string;
/**
* Force content warning
*/
"mandatoryCWForNote": string;
/**
* Applies an additional content warning to this post. The new warning will appear like a word mute to distinguish it from the author's own content warning.
*/
"mandatoryCWForNoteDescription": string;
/**
* Force content warning
*/
"mandatoryCWForInstance": string;
/**
* Applies a content warning to all posts originating from this instance. The forced warnings will appear like a word mute to distinguish them from the notes' own content warnings.
*/
"mandatoryCWForInstanceDescription": string;
/**
* Fetch linked note
*/

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddNoteMandatoryCW1751077195277 {
name = 'AddNoteMandatoryCW1751077195277'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD "mandatoryCW" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "mandatoryCW"`);
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddInstanceMandatoryCW1751078046239 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" ADD "mandatoryCW" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "mandatoryCW"`);
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ReplaceInstanceIsNSFW1751236746539 {
name = 'ReplaceInstanceIsNSFW1751236746539'
async up(queryRunner) {
// Data migration
await queryRunner.query(`UPDATE "instance" SET "mandatoryCW" = 'NSFW' WHERE "isNSFW" = true`);
await queryRunner.query(`UPDATE "note" SET "cw" = null WHERE "cw" = 'Instance is marked as NSFW'`);
// Schema migration
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isNSFW"`);
}
async down(queryRunner) {
// Schema migration
await queryRunner.query(`ALTER TABLE "instance" ADD "isNSFW" boolean NOT NULL DEFAULT false`);
// Data migration
await queryRunner.query(`UPDATE "instance" SET "isNSFW" = true WHERE "mandatoryCW" ILIKE '%NSFW%'`);
}
}

View file

@ -20,6 +20,7 @@ import { EnvService } from '@/core/EnvService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApLogService } from '@/core/ApLogService.js';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AnnouncementService } from './AnnouncementService.js';
@ -240,6 +241,7 @@ const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisti
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $TimeService: Provider = { provide: 'TimeService', useExisting: TimeService };
const $EnvService: Provider = { provide: 'EnvService', useExisting: EnvService };
const $NoteVisibilityService: Provider = { provide: 'NoteVisibilityService', useExisting: NoteVisibilityService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@ -400,6 +402,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ReversiService,
TimeService,
EnvService,
NoteVisibilityService,
ChartLoggerService,
FederationChart,
@ -556,6 +559,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$ReversiService,
$TimeService,
$EnvService,
$NoteVisibilityService,
$ChartLoggerService,
$FederationChart,
@ -713,6 +717,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ReversiService,
TimeService,
EnvService,
NoteVisibilityService,
FederationChart,
NotesChart,
@ -867,6 +872,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$ReversiService,
$TimeService,
$EnvService,
$NoteVisibilityService,
$FederationChart,
$NotesChart,

View file

@ -19,6 +19,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { NotePopulationData, NoteVisibilityService, PopulatedNote } from '@/core/NoteVisibilityService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
type TimelineOptions = {
untilId: string | null,
@ -29,7 +31,6 @@ type TimelineOptions = {
useDbFallback: boolean,
redisTimelines: FanoutTimelineName[],
noteFilter?: (note: MiNote) => boolean,
alwaysIncludeMyNotes?: boolean;
ignoreAuthorFromBlock?: boolean;
ignoreAuthorFromMute?: boolean;
ignoreAuthorFromInstanceBlock?: boolean;
@ -37,7 +38,9 @@ type TimelineOptions = {
excludeReplies?: boolean;
excludeBots?: boolean;
excludePureRenotes: boolean;
includeMutedNotes?: boolean;
ignoreAuthorFromUserSuspension?: boolean;
ignoreAuthorFromUserSilence?: boolean;
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
};
@ -54,6 +57,8 @@ export class FanoutTimelineEndpointService {
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
private utilityService: UtilityService,
private readonly noteVisibilityService: NoteVisibilityService,
private readonly federatedInstanceService: FederatedInstanceService,
) {
}
@ -80,86 +85,67 @@ export class FanoutTimelineEndpointService {
const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId;
if (!shouldFallbackToDb) {
let filter = ps.noteFilter ?? (_note => true);
if (ps.alwaysIncludeMyNotes && ps.me) {
const me = ps.me;
const parentFilter = filter;
filter = (note) => note.userId === me.id || parentFilter(note);
}
let filter: (note: MiNote, populated: PopulatedNote) => boolean = ps.noteFilter ?? (() => true);
if (ps.excludeNoFiles) {
const parentFilter = filter;
filter = (note) => note.fileIds.length !== 0 && parentFilter(note);
filter = (note, populated) => note.fileIds.length !== 0 && parentFilter(note, populated);
}
if (ps.excludeReplies) {
const parentFilter = filter;
filter = (note) => !isReply(note, ps.me?.id) && parentFilter(note);
filter = (note, populated) => {
if (note.userId !== ps.me?.id && isReply(note, ps.me?.id)) return false;
return parentFilter(note, populated);
};
}
if (ps.excludeBots) {
const parentFilter = filter;
filter = (note) => !note.user?.isBot && parentFilter(note);
filter = (note, populated) => !note.user?.isBot && parentFilter(note, populated);
}
if (ps.excludePureRenotes) {
const parentFilter = filter;
filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note);
filter = (note, populated) => (!isRenote(note) || isQuote(note)) && parentFilter(note, populated);
}
if (ps.me) {
const me = ps.me;
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(ps.me.id),
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
this.cacheService.userBlockedCache.fetch(ps.me.id),
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
]);
{
const me = ps.me ? await this.cacheService.findUserById(ps.me.id) : null;
const data = await this.noteVisibilityService.populateData(me);
const parentFilter = filter;
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
filter = (note, populated) => {
const { accessible, silence } = this.noteVisibilityService.checkNoteVisibility(populated, me, { data, filters: { includeSilencedAuthor: ps.ignoreAuthorFromUserSilence } });
if (!accessible || silence) return false;
return parentFilter(note);
return parentFilter(note, populated);
};
}
{
const parentFilter = filter;
filter = (note) => {
filter = (note, populated) => {
if (!ps.ignoreAuthorFromInstanceBlock) {
if (note.userInstance?.isBlocked) return false;
if (note.user?.instance?.isBlocked) return false;
}
if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false;
if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false;
if (note.userId !== note.renoteUserId && note.renote?.user?.instance?.isBlocked) return false;
if (note.userId !== note.replyUserId && note.reply?.user?.instance?.isBlocked) return false;
return parentFilter(note);
return parentFilter(note, populated);
};
}
{
const parentFilter = filter;
filter = (note) => {
const noteJoined = note as MiNote & {
renoteUser: MiUser | null;
replyUser: MiUser | null;
};
filter = (note, populated) => {
if (!ps.ignoreAuthorFromUserSuspension) {
if (note.user!.isSuspended) return false;
if (note.user?.isSuspended) return false;
}
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false;
if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false;
return parentFilter(note);
return parentFilter(note, populated);
};
}
@ -204,23 +190,117 @@ export class FanoutTimelineEndpointService {
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
}
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote, populated: PopulatedNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('note.userInstance', 'userInstance')
.leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance')
.leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance');
const notes = (await query.getMany()).filter(noteFilter);
// Needed for populated note
.leftJoinAndSelect('renote.reply', 'renoteReply')
;
notes.sort((a, b) => idCompare(a.id, b.id));
const notes = await query.getMany();
return notes;
const populatedNotes = await this.populateNotes(notes);
return populatedNotes
.filter(({ note, populated }) => noteFilter(note, populated))
.sort((a, b) => idCompare(a.id, b.id))
.map(({ note }) => note);
}
/**
* Given a sample of notes to return, populates the relations from cache and generates a NotePopulationData hint object.
* This is messy and kinda gross, but it allows us to use the synchronous checkNoteVisibility from within the filter callbacks.
*/
private async populateNotes(notes: MiNote[]): Promise<{ id: string, note: MiNote, populated: PopulatedNote }[]> {
// Manually populate user/instance since it's cacheable and avoids many joins.
// These fields *must* be populated or NoteVisibilityService won't work right!
const populationData = await this.populateUsers(notes);
// This is async, but it should never await because we populate above.
return await Promise.all(notes.map(async note => ({
id: note.id,
note: note,
populated: await this.noteVisibilityService.populateNote(note, populationData),
})));
}
/**
* This does two things:
* 1. Populates the user/instance relations of every note in the object graph.
* 2. Returns fetched note/user/instance maps for use as hint data for NoteVisibilityService.
*/
private async populateUsers(notes: MiNote[]): Promise<NotePopulationData> {
// Enumerate all related data
const allNotes = new Map<string, MiNote>();
const usersToFetch = new Set<string>();
const instancesToFetch = new Set<string>();
for (const note of notes) {
// note
allNotes.set(note.id, note);
usersToFetch.add(note.userId);
if (note.userHost) {
instancesToFetch.add(note.userHost);
}
// note.reply
if (note.reply) {
allNotes.set(note.reply.id, note.reply);
usersToFetch.add(note.reply.userId);
if (note.reply.userHost) {
instancesToFetch.add(note.reply.userHost);
}
}
// note.renote
if (note.renote) {
allNotes.set(note.renote.id, note.renote);
usersToFetch.add(note.renote.userId);
if (note.renote.userHost) {
instancesToFetch.add(note.renote.userHost);
}
}
// note.renote.reply
if (note.renote?.reply) {
allNotes.set(note.renote.reply.id, note.renote.reply);
usersToFetch.add(note.renote.reply.userId);
if (note.renote.reply.userHost) {
instancesToFetch.add(note.renote.reply.userHost);
}
}
}
// Fetch everything and populate users
const [users, instances] = await Promise.all([
this.cacheService.getUsers(usersToFetch),
this.federatedInstanceService.federatedInstanceCache.fetchMany(instancesToFetch).then(i => new Map(i)),
]);
for (const [id, user] of Array.from(users)) {
users.set(id, {
...user,
instance: (user.host && instances.get(user.host)) || null,
});
}
// Assign users back to notes
for (const note of notes) {
note.user = users.get(note.userId) ?? null;
if (note.reply) {
note.reply.user = users.get(note.reply.userId) ?? null;
}
if (note.renote) {
note.renote.user = users.get(note.renote.userId) ?? null;
if (note.renote.reply) {
note.renote.reply.user = users.get(note.renote.reply.userId) ?? null;
}
}
}
// Optimization: return our accumulated data to avoid duplicate lookups later
return { users, instances, notes: allNotes };
}
}

View file

@ -5,37 +5,72 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import { In } from 'typeorm';
import type { InstancesRepository, MiMeta } from '@/models/_.js';
import type { MiInstance } from '@/models/Instance.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { Serialized } from '@/types.js';
import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
import { diffArraysSimple } from '@/misc/diff-arrays.js';
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
import { InternalEventService } from '@/core/InternalEventService.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@Injectable()
export class FederatedInstanceService implements OnApplicationShutdown {
private readonly federatedInstanceCache: MemoryKVCache<MiInstance | null>;
public readonly federatedInstanceCache: QuantumKVCache<MiInstance>;
constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
@Inject(DI.meta)
private readonly meta: MiMeta,
private utilityService: UtilityService,
private idService: IdService,
private readonly internalEventService: InternalEventService,
) {
this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m
this.redisForSub.on('message', this.onMessage);
this.federatedInstanceCache = new QuantumKVCache(this.internalEventService, 'federatedInstance', {
lifetime: 1000 * 60 * 3, // 3 minutes
fetcher: async key => {
const host = this.utilityService.toPuny(key);
let instance = await this.instancesRepository.findOneBy({ host });
if (instance == null) {
await this.instancesRepository.createQueryBuilder('instance')
.insert()
.values({
id: this.idService.gen(),
host,
firstRetrievedAt: new Date(),
isBlocked: this.utilityService.isBlockedHost(host),
isSilenced: this.utilityService.isSilencedHost(host),
isMediaSilenced: this.utilityService.isMediaSilencedHost(host),
isAllowListed: this.utilityService.isAllowListedHost(host),
isBubbled: this.utilityService.isBubbledHost(host),
})
.orIgnore()
.execute();
instance = await this.instancesRepository.findOneByOrFail({ host });
}
return instance;
},
bulkFetcher: async keys => {
const hosts = keys.map(key => this.utilityService.toPuny(key));
const instances = await this.instancesRepository.findBy({ host: In(hosts) });
return instances.map(i => [i.host, i]);
},
});
this.internalEventService.on('metaUpdated', this.onMetaUpdated);
}
@bindThis
public async fetchOrRegister(host: string): Promise<MiInstance> {
return this.federatedInstanceCache.fetch(host);
/*
host = this.utilityService.toPuny(host);
const cached = this.federatedInstanceCache.get(host);
@ -61,12 +96,15 @@ export class FederatedInstanceService implements OnApplicationShutdown {
index = await this.instancesRepository.findOneByOrFail({ host });
}
this.federatedInstanceCache.set(host, index);
await this.federatedInstanceCache.set(host, index);
return index;
*/
}
@bindThis
public async fetch(host: string): Promise<MiInstance | null> {
public async fetch(host: string): Promise<MiInstance> {
return this.federatedInstanceCache.fetch(host);
/*
host = this.utilityService.toPuny(host);
const cached = this.federatedInstanceCache.get(host);
@ -75,29 +113,54 @@ export class FederatedInstanceService implements OnApplicationShutdown {
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
this.federatedInstanceCache.set(host, null);
await this.federatedInstanceCache.set(host, null);
return null;
} else {
this.federatedInstanceCache.set(host, index);
await this.federatedInstanceCache.set(host, index);
return index;
}
*/
}
@bindThis
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
public async update(id: MiInstance['id'], data: QueryDeepPartialEntity<MiInstance>): Promise<MiInstance> {
const result = await this.instancesRepository.createQueryBuilder().update()
.set(data)
.where('id = :id', { id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
return response.raw[0] as MiInstance;
});
this.federatedInstanceCache.set(result.host, result);
await this.federatedInstanceCache.set(result.host, result);
return result;
}
private syncCache(before: Serialized<MiMeta | undefined>, after: Serialized<MiMeta>): void {
/**
* Gets all instances in the allowlist (meta.federationHosts).
*/
@bindThis
public async getAllowList(): Promise<MiInstance[]> {
const allowedHosts = new Set(this.meta.federationHosts);
this.meta.blockedHosts.forEach(h => allowedHosts.delete(h));
const instances = await this.federatedInstanceCache.fetchMany(this.meta.federationHosts);
return instances.map(i => i[1]);
}
/**
* Gets all instances in the denylist (meta.blockedHosts).
*/
@bindThis
public async getDenyList(): Promise<MiInstance[]> {
const instances = await this.federatedInstanceCache.fetchMany(this.meta.blockedHosts);
return instances.map(i => i[1]);
}
// This gets fired *in each process* so don't do anything to trigger cache notifications!
private syncCache(before: MiMeta | undefined, after: MiMeta): void {
const changed =
diffArraysSimple(before?.blockedHosts, after.blockedHosts) ||
diffArraysSimple(before?.silencedHosts, after.silencedHosts) ||
@ -112,20 +175,13 @@ export class FederatedInstanceService implements OnApplicationShutdown {
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
if (type === 'metaUpdated') {
this.syncCache(body.before, body.after);
}
}
private async onMetaUpdated(body: { before?: MiMeta; after: MiMeta; }) {
this.syncCache(body.before, body.after);
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.internalEventService.off('metaUpdated', this.onMetaUpdated);
this.federatedInstanceCache.dispose();
}

View file

@ -144,6 +144,7 @@ type Option = {
url?: string | null;
app?: MiApp | null;
processErrors?: string[] | null;
mandatoryCW?: string | null;
};
export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
@ -414,14 +415,6 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
if (user.host && !data.cw) {
await this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (i.isNSFW && !this.isPureRenote(data)) {
data.cw = 'Instance is marked as NSFW';
}
});
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
@ -485,6 +478,7 @@ export class NoteCreateService implements OnApplicationShutdown {
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
processErrors: data.processErrors,
mandatoryCW: data.mandatoryCW,
});
// should really not happen, but better safe than sorry
@ -994,7 +988,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// 自分自身のHTL
if (note.userHost == null) {
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) {
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id) || note.userId === user.id) {
this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);

View file

@ -118,7 +118,7 @@ type MinimumUser = {
uri: MiUser['uri'];
};
type Option = {
export type Option = {
createdAt?: Date | null;
name?: string | null;
text?: string | null;
@ -141,6 +141,7 @@ type Option = {
updatedAt?: Date | null;
editcount?: boolean | null;
processErrors?: string[] | null;
mandatoryCW?: string | null;
};
@Injectable()
@ -224,13 +225,7 @@ export class NoteEditService implements OnApplicationShutdown {
}
@bindThis
public async edit(user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, editid: MiNote['id'], data: Option, silent = false): Promise<MiNote> {
public async edit(user: MiUser, editid: MiNote['id'], data: Option, silent = false): Promise<MiNote> {
if (!editid) {
throw new UnrecoverableError('edit failed: missing editid');
}
@ -379,8 +374,6 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.text === '') {
data.text = null;
}
} else {
data.text = null;
}
const maxCwLength = user.host == null
@ -395,8 +388,6 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.cw === '') {
data.cw = null;
}
} else {
data.cw = null;
}
let tags = data.apHashtags;
@ -443,28 +434,23 @@ export class NoteEditService implements OnApplicationShutdown {
}
}
if (user.host && !data.cw) {
await this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
if (i.isNSFW && !this.noteCreateService.isPureRenote(data)) {
data.cw = 'Instance is marked as NSFW';
}
});
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const update: Partial<MiNote> = {};
if (data.text !== oldnote.text) {
if (data.text !== undefined && data.text !== oldnote.text) {
update.text = data.text;
}
if (data.cw !== oldnote.cw) {
if (data.cw !== undefined && data.cw !== oldnote.cw) {
update.cw = data.cw;
}
if (oldnote.hasPoll !== !!data.poll) {
if (data.poll !== undefined && oldnote.hasPoll !== !!data.poll) {
update.hasPoll = !!data.poll;
}
if (data.mandatoryCW !== undefined && oldnote.mandatoryCW !== data.mandatoryCW) {
update.mandatoryCW = data.mandatoryCW;
}
// TODO deep-compare files
const filesChanged = oldnote.fileIds.length || data.files?.length;
@ -526,6 +512,7 @@ export class NoteEditService implements OnApplicationShutdown {
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
reactionAndUserPairCache: oldnote.reactionAndUserPairCache,
mandatoryCW: data.mandatoryCW,
});
if (data.uri != null) note.uri = data.uri;

View file

@ -0,0 +1,465 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { CacheService } from '@/core/CacheService.js';
import type { MiNote } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js';
import { bindThis } from '@/decorators.js';
import type { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { MiFollowing, MiInstance, NotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
/**
* Visibility level for a given user towards a given post.
*/
export interface NoteVisibilityResult {
/**
* Whether the user has access to view this post.
*/
accessible: boolean;
/**
* If the user should be shown only a redacted version of the post.
* (see NoteEntityService.hideNote() for details.)
*/
redact: boolean;
/**
* If false, the note should be visible by default. (normal case)
* If true, the note should be hidden by default. (Silences, mutes, etc.)
* If "timeline", the note should be hidden in timelines only. (following w/o replies)
*/
silence: boolean;
}
export interface NoteVisibilityFilters {
/**
* If false, exclude replies to other users unless the "include replies to others in timeline" has been enabled for the note's author.
* If true (default), then replies are treated like any other post.
*/
includeReplies?: boolean;
/**
* If true, treat the note's author as never being silenced. Does not apply to reply or renote targets, unless they're by the same author.
* If false (default), then silence is enforced for all notes.
*/
includeSilencedAuthor?: boolean;
}
@Injectable()
export class NoteVisibilityService {
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
private readonly cacheService: CacheService,
private readonly idService: IdService,
private readonly federatedInstanceService: FederatedInstanceService,
) {}
@bindThis
public async checkNoteVisibilityAsync(note: MiNote | Packed<'Note'>, user: string | PopulatedMe, opts?: { filters?: NoteVisibilityFilters, hint?: Partial<NoteVisibilityData> }): Promise<NoteVisibilityResult> {
if (typeof(user) === 'string') {
user = await this.cacheService.findUserById(user);
}
const populatedNote = await this.populateNote(note, opts?.hint);
const populatedData = await this.populateData(user, opts?.hint ?? {});
return this.checkNoteVisibility(populatedNote, user, { filters: opts?.filters, data: populatedData });
}
@bindThis
public async populateNote(note: MiNote | Packed<'Note'>, hint?: NotePopulationData, diveReply = true, diveRenote = true): Promise<PopulatedNote> {
const userPromise = this.getNoteUser(note, hint);
// noinspection ES6MissingAwait
return await awaitAll({
id: note.id,
threadId: note.threadId ?? note.id,
createdAt: 'createdAt' in note
? new Date(note.createdAt)
: this.idService.parse(note.id).date,
userId: note.userId,
userHost: userPromise.then(u => u.host),
user: userPromise,
renoteId: note.renoteId ?? null,
renote: diveRenote ? this.getNoteRenote(note, hint) : null,
replyId: note.replyId ?? null,
reply: diveReply ? this.getNoteReply(note, hint) : null,
hasPoll: 'hasPoll' in note ? note.hasPoll : (note.poll != null),
mentions: note.mentions ?? [],
visibleUserIds: note.visibleUserIds ?? [],
visibility: note.visibility,
text: note.text,
cw: note.cw ?? null,
fileIds: note.fileIds ?? [],
});
}
private async getNoteUser(note: MiNote | Packed<'Note'>, hint?: NotePopulationData): Promise<PopulatedUser> {
const user = note.user
?? hint?.users?.get(note.userId)
?? await this.cacheService.findUserById(note.userId);
const instance = user.host
? (
user.instance
?? hint?.instances?.get(user.host)
?? await this.federatedInstanceService.fetchOrRegister(user.host)
) : null;
return {
...user,
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? null,
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? null,
requireSigninToViewContents: user.requireSigninToViewContents ?? false,
instance: instance ? {
...instance,
host: user.host as string,
} : null,
};
}
private async getNoteRenote(note: MiNote | Packed<'Note'>, hint?: NotePopulationData): Promise<PopulatedNote | null> {
if (!note.renoteId) return null;
const renote = note.renote
?? hint?.notes?.get(note.renoteId)
?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
// Renote needs to include the reply!
// This will dive one more time before landing in getNoteReply, which terminates recursion.
// Based on the logic in NoteEntityService.pack()
return await this.populateNote(renote, hint, true, false);
}
private async getNoteReply(note: MiNote | Packed<'Note'>, hint?: NotePopulationData): Promise<PopulatedNote | null> {
if (!note.replyId) return null;
const reply = note.reply
?? hint?.notes?.get(note.replyId)
?? await this.notesRepository.findOneByOrFail({ id: note.replyId });
return await this.populateNote(reply, hint, false, false);
}
@bindThis
public async populateData(user: PopulatedMe, hint?: Partial<NoteVisibilityData>): Promise<NoteVisibilityData> {
// noinspection ES6MissingAwait
const [
userBlockers,
userFollowings,
userMutedThreads,
userMutedNotes,
userMutedUsers,
userMutedUserRenotes,
userMutedInstances,
] = await Promise.all([
user ? (hint?.userBlockers ?? this.cacheService.userBlockedCache.fetch(user.id)) : null,
user ? (hint?.userFollowings ?? this.cacheService.userFollowingsCache.fetch(user.id)) : null,
user ? (hint?.userMutedThreads ?? this.cacheService.threadMutingsCache.fetch(user.id)) : null,
user ? (hint?.userMutedNotes ?? this.cacheService.noteMutingsCache.fetch(user.id)) : null,
user ? (hint?.userMutedUsers ?? this.cacheService.userMutingsCache.fetch(user.id)) : null,
user ? (hint?.userMutedUserRenotes ?? this.cacheService.renoteMutingsCache.fetch(user.id)) : null,
user ? (hint?.userMutedInstances ?? this.cacheService.userProfileCache.fetch(user.id).then(p => new Set(p.mutedInstances))) : null,
]);
return {
userBlockers,
userFollowings,
userMutedThreads,
userMutedNotes,
userMutedUsers,
userMutedUserRenotes,
userMutedInstances,
};
}
@bindThis
public checkNoteVisibility(note: PopulatedNote, user: PopulatedMe, opts: { filters?: NoteVisibilityFilters, data: NoteVisibilityData }): NoteVisibilityResult {
// Copy note since we mutate it below
note = {
...note,
renote: note.renote ? {
...note.renote,
renote: note.renote.renote ? { ...note.renote.renote } : null,
reply: note.renote.reply ? { ...note.renote.reply } : null,
} : null,
reply: note.reply ? {
...note.reply,
renote: note.reply.renote ? { ...note.reply.renote } : null,
reply: note.reply.reply ? { ...note.reply.reply } : null,
} : null,
} as PopulatedNote;
this.syncVisibility(note);
return this.checkNoteVisibilityFor(note, user, opts);
}
private checkNoteVisibilityFor(note: PopulatedNote, user: PopulatedMe, opts: { filters?: NoteVisibilityFilters, data: NoteVisibilityData }): NoteVisibilityResult {
const accessible = this.isAccessible(note, user, opts.data);
const redact = !accessible || this.shouldRedact(note, user);
const silence = this.shouldSilence(note, user, opts.data, opts.filters);
// For boosts (pure renotes), we must recurse and pick the lowest common access level.
if (isPopulatedBoost(note)) {
const boostVisibility = this.checkNoteVisibilityFor(note.renote, user, opts);
return {
accessible: accessible && boostVisibility.accessible,
redact: redact || boostVisibility.redact,
silence: silence || boostVisibility.silence,
};
}
return { accessible, redact, silence };
}
// Based on NoteEntityService.isVisibleForMe
private isAccessible(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData): boolean {
// We can always view our own notes
if (user?.id === note.userId) return true;
// We can *never* view blocked notes
if (data.userBlockers?.has(note.userId)) return false;
if (note.visibility === 'specified') {
return this.isAccessibleDM(note, user);
} else if (note.visibility === 'followers') {
return this.isAccessibleFO(note, user, data);
} else {
return true;
}
}
private isAccessibleDM(note: PopulatedNote, user: PopulatedMe): boolean {
// Must be logged in to view DM
if (user == null) return false;
// Can be visible to me
if (note.visibleUserIds.includes(user.id)) return true;
// Otherwise invisible
return false;
}
private isAccessibleFO(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData): boolean {
// Must be logged in to view FO
if (user == null) return false;
// Can be a reply to me
if (note.reply?.userId === user.id) return true;
// Can mention me
if (note.mentions.includes(user.id)) return true;
// Can be visible to me
if (note.visibleUserIds.includes(user.id)) return true;
// Can be followed by me
if (data.userFollowings?.has(note.userId)) return true;
// Can be two remote users, since we can't verify remote->remote following.
if (note.userHost != null && user.host != null) return true;
// Otherwise invisible
return false;
}
// Based on NoteEntityService.treatVisibility
@bindThis
public syncVisibility(note: PopulatedNote | Packed<'Note'>): void {
// Make followers-only
if (note.user.makeNotesFollowersOnlyBefore && note.visibility !== 'specified' && note.visibility !== 'followers') {
const followersOnlyBefore = note.user.makeNotesFollowersOnlyBefore * 1000;
const createdAt = new Date(note.createdAt).valueOf();
// I don't understand this logic, but I tried to break it out for readability
const followersOnlyOpt1 = followersOnlyBefore <= 0 && (Date.now() - createdAt > 0 - followersOnlyBefore);
const followersOnlyOpt2 = followersOnlyBefore > 0 && (createdAt < followersOnlyBefore);
if (followersOnlyOpt1 || followersOnlyOpt2) {
note.visibility = 'followers';
}
}
// Recurse
if (note.renote) {
this.syncVisibility(note.renote);
}
if (note.reply) {
this.syncVisibility(note.reply);
}
}
// Based on NoteEntityService.hideNote
private shouldRedact(note: PopulatedNote, user: PopulatedMe): boolean {
// Never redact our own notes
if (user?.id === note.userId) return false;
// Redact if sign-in required
if (note.user.requireSigninToViewContents && !user) return true;
// Redact if note has expired
if (note.user.makeNotesHiddenBefore) {
const hiddenBefore = note.user.makeNotesHiddenBefore * 1000;
const createdAt = note.createdAt.valueOf();
// I don't understand this logic, but I tried to break it out for readability
const hiddenOpt1 = hiddenBefore <= 0 && (Date.now() - createdAt > 0 - hiddenBefore);
const hiddenOpt2 = hiddenBefore > 0 && (createdAt < hiddenBefore);
if (hiddenOpt1 || hiddenOpt2) return true;
}
// Otherwise don't redact
return false;
}
// Based on inconsistent logic from all around the app
private shouldSilence(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData, filters: NoteVisibilityFilters | undefined): boolean {
if (this.shouldSilenceForMute(note, data)) {
return true;
}
if (this.shouldSilenceForSilence(note, user, data, filters?.includeSilencedAuthor ?? false)) {
return true;
}
if (!filters?.includeReplies && this.shouldSilenceForFollowWithoutReplies(note, user, data)) {
return true;
}
return false;
}
private shouldSilenceForMute(note: PopulatedNote, data: NoteVisibilityData): boolean {
// Silence if we've muted the thread
if (data.userMutedThreads?.has(note.threadId)) return true;
// Silence if we've muted the note
if (data.userMutedNotes?.has(note.id)) return true;
// Silence if we've muted the user
if (data.userMutedUsers?.has(note.userId)) return true;
// Silence if we've muted renotes from the user
if (isPopulatedBoost(note) && data.userMutedUserRenotes?.has(note.userId)) return true;
// Silence if we've muted the instance
if (note.userHost && data.userMutedInstances?.has(note.userHost)) return true;
// Otherwise don't silence
return false;
}
private shouldSilenceForSilence(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData, ignoreSilencedAuthor: boolean): boolean {
// Don't silence if it's us
if (note.userId === user?.id) return false;
// Don't silence if we're following or ignoring the author
if (!data.userFollowings?.has(note.userId) && !ignoreSilencedAuthor) {
// Silence if user is silenced
if (note.user.isSilenced) return true;
// Silence if user instance is silenced
if (note.user.instance?.isSilenced) return true;
}
// Silence if renote is silenced
if (note.renote && note.renote.userId !== note.userId && this.shouldSilenceForSilence(note.renote, user, data, false)) return true;
// Silence if reply is silenced
if (note.reply && note.reply.userId !== note.userId && this.shouldSilenceForSilence(note.reply, user, data, false)) return true;
// Otherwise don't silence
return false;
}
private shouldSilenceForFollowWithoutReplies(note: PopulatedNote, user: PopulatedMe, data: NoteVisibilityData): boolean {
// Don't silence if it's not a reply
if (!note.reply) return false;
// Don't silence if it's a self-reply
if (note.reply.userId === note.userId) return false;
// Don't silence if it's a reply to us
if (note.reply.userId === user?.id) return false;
// Don't silence if it's our post
if (note.userId === user?.id) return false;
// Don't silence if we follow w/ replies
if (user && data.userFollowings?.get(user.id)?.withReplies) return false;
// Silence otherwise
return true;
}
}
export interface NoteVisibilityData extends NotePopulationData {
userBlockers: Set<string> | null;
userFollowings: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> | null;
userMutedThreads: Set<string> | null;
userMutedNotes: Set<string> | null;
userMutedUsers: Set<string> | null;
userMutedUserRenotes: Set<string> | null;
userMutedInstances: Set<string> | null;
}
export interface NotePopulationData {
notes?: Map<string, MiNote>;
users?: Map<string, MiUser>;
instances?: Map<string, MiInstance>;
}
// This represents the *requesting* user!
export type PopulatedMe = Pick<MiUser, 'id' | 'host'> | null | undefined;
export interface PopulatedNote {
id: string;
threadId: string;
userId: string;
userHost: string | null;
user: PopulatedUser;
renoteId: string | null;
renote: PopulatedNote | null;
replyId: string | null;
reply: PopulatedNote | null;
mentions: string[];
visibleUserIds: string[];
visibility: 'public' | 'followers' | 'home' | 'specified';
createdAt: Date;
text: string | null;
cw: string | null;
hasPoll: boolean;
fileIds: string[];
}
interface PopulatedUser {
id: string;
host: string | null;
instance: PopulatedInstance | null;
isSilenced: boolean;
requireSigninToViewContents: boolean;
makeNotesHiddenBefore: number | null;
makeNotesFollowersOnlyBefore: number | null;
}
interface PopulatedInstance {
host: string;
isSilenced: boolean;
}
function isPopulatedBoost(note: PopulatedNote): note is PopulatedNote & { renote: PopulatedNote } {
return note.renoteId != null
&& note.replyId == null
&& note.text == null
&& note.cw == null
&& note.fileIds.length === 0
&& !note.hasPoll;
}

View file

@ -7,7 +7,6 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import { MiInstance } from '@/models/Instance.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@ -81,6 +80,35 @@ export class QueryService {
return q;
}
/**
* Exclude replies from the queries, used for timelines.
* withRepliesProp can be specified to additionally allow replies when a given property is true.
* Must match logic NoteVisibilityService.shouldSilenceForFollowWithoutReplies.
*/
@bindThis
public generateExcludedRepliesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null, withRepliesProp?: string): SelectQueryBuilder<E> {
return q
.andWhere(new Brackets(qb => {
if (withRepliesProp) {
// Allow if query specifies it
qb.orWhere(`${withRepliesProp} = true`);
}
return this
// Allow if we're following w/ replies
.orFollowingUser(qb, ':meId', 'note.userId', true)
// Allow if it's not a reply
.orWhere('note.replyId IS NULL') // 返信ではない
// Allow if it's a self-reply (user replied to themself)
.orWhere('note.replyUserId = note.userId')
// Allow if it's a reply to me
.orWhere('note.replyUserId = :meId')
// Allow if it's my reply
.orWhere('note.userId = :meId');
}))
.setParameters({ meId: me?.id ?? null });
}
// ここでいうBlockedは被Blockedの意
@bindThis
public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
@ -107,38 +135,66 @@ export class QueryService {
@bindThis
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
// Muted thread
this.andNotMutingThread(q, ':meId', 'coalesce(note.threadId, note.id)');
// Muted note
this.andNotMutingNote(q, ':meId', 'note.id');
q.andWhere(new Brackets(qb => qb
.orWhere('note.renoteId IS NULL')
.orWhere(new Brackets(qbb => {
// Renote muted thread
this.andNotMutingThread(qbb, ':meId', 'coalesce(renote.threadId, renote.id)');
// Renote muted note
this.andNotMutingNote(qbb, ':meId', 'renote.id');
}))));
return this
.andNotMutingThread(q, ':meId', 'note.id')
.andWhere(new Brackets(qb => this
.orNotMutingThread(qb, ':meId', 'note.threadId')
.orWhere('note.threadId IS NULL')))
.leftJoin(q, 'note.renote', 'renote')
.setParameters({ meId: me.id });
}
@bindThis
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
// 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
return this
.andNotMutingUser(q, ':meId', 'note.userId', exclude)
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, excludeAuthor = false): SelectQueryBuilder<E> {
if (!excludeAuthor) {
this
// muted user
.andNotMutingUser(q, ':meId', 'note.userId')
// muted host
.andWhere(new Brackets(qb => {
qb.orWhere('note.userHost IS NULL');
this.orFollowingUser(qb, ':meId', 'note.userId');
this.orNotMutingInstance(qb, ':meId', 'note.userHost');
}));
}
return q
// muted reply user
.andWhere(new Brackets(qb => this
.orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
.orNotMutingUser(qb, ':meId', 'note.replyUserId')
.orWhere('note.replyUserId = note.userId')
.orWhere('note.replyUserId IS NULL')))
// muted renote user
.andWhere(new Brackets(qb => this
.orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
.orNotMutingUser(qb, ':meId', 'note.renoteUserId')
.orWhere('note.renoteUserId = note.userId')
.orWhere('note.renoteUserId IS NULL')))
// TODO exclude should also pass a host to skip these instances
// mute instances
.andWhere(new Brackets(qb => this
.andNotMutingInstance(qb, ':meId', 'note.userHost')
.orWhere('note.userHost IS NULL')))
.andWhere(new Brackets(qb => this
.orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
.orWhere('note.replyUserHost IS NULL')))
.andWhere(new Brackets(qb => this
.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
.orWhere('note.renoteUserHost IS NULL')))
// muted reply host
.andWhere(new Brackets(qb => {
qb.orWhere('note.replyUserHost IS NULL');
qb.orWhere('note.replyUserHost = note.userHost');
this.orFollowingUser(qb, ':meId', 'note.replyUserId');
this.orNotMutingInstance(qb, ':meId', 'note.replyUserHost');
}))
// muted renote host
.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NULL');
qb.orWhere('note.renoteUserHost = note.userHost');
this.orFollowingUser(qb, ':meId', 'note.renoteUserId');
this.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost');
}))
.setParameters({ meId: me.id });
}
@ -154,7 +210,7 @@ export class QueryService {
// For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user.
@bindThis
public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
// This code must always be synchronized with the checks in NoteEntityService.isVisibleForMe.
return q.andWhere(new Brackets(qb => {
// Public post
qb.orWhere('note.visibility = \'public\'')
@ -204,14 +260,15 @@ export class QueryService {
@bindThis
public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
.leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
.leftJoin(q, `note.${key}Instance`, `${key}Instance`)
.andWhere(new Brackets(qb => {
qb
.orWhere(`"${key}Instance" IS NULL`) // local
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
if (excludeAuthor) {
qb.orWhere(`note.userId = note.${key}Id`); // author
if (key !== 'user') {
// Don't re-check self-replies and self-renote targets
qb.orWhere(`note.userId = note.${key}Id`);
}
}));
@ -225,33 +282,119 @@ export class QueryService {
}
@bindThis
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
if (!me) {
return q.andWhere('user.isSilenced = false');
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null, excludeAuthor = false): SelectQueryBuilder<E> {
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser', userKey: 'note.user' | 'reply.user' | 'renote.user') => {
// These are de-duplicated, since most call sites already provide some of them.
this.leftJoin(q, `note.${key}Instance`, `${key}Instance`); // note->instance
this.leftJoin(q, userKey, key); // note->user
q.andWhere(new Brackets(qb => {
// case 1: user does not exist (note is not reply/renote)
qb.orWhere(`note.${key}Id IS NULL`);
// case 2: user not silenced AND (instance not silenced OR instance is local)
qb.orWhere(new Brackets(qbb => qbb
.andWhere(`"${key}"."isSilenced" = false`)
.andWhere(new Brackets(qbbb => qbbb
.orWhere(`"${key}Instance"."isSilenced" = false`)
.orWhere(`"note"."${key}Host" IS NULL`)))));
if (me) {
// case 3: we are the author
qb.orWhere(`note.${key}Id = :meId`);
// case 4: we are following the user
this.orFollowingUser(qb, ':meId', `note.${key}Id`);
}
// case 5: user is the same
if (key !== 'user') {
qb.orWhere(`note.${key}Id = note.userId`);
}
}));
};
const checkForRenote = (_q: WhereExpressionBuilder, key: 'replyUser' | 'renoteUser', userRel: 'renoteReply.user' | 'renoteRenote.user', userAlias: 'renoteReplyUser' | 'renoteRenoteUser') => {
const instanceAlias = `${userAlias}Instance`;
this.leftJoin(q, `renote.${key}Instance`, instanceAlias); // note->instance
this.leftJoin(q, userRel, userAlias); // note->user
_q.andWhere(new Brackets(qb => {
// case 1: user does not exist (note is not reply/renote)
qb.orWhere(`renote.${key}Id IS NULL`);
// case 2: user not silenced AND (instance not silenced OR instance is local)
qb.orWhere(new Brackets(qbb => qbb
.andWhere(`"${userAlias}"."isSilenced" = false`)
.andWhere(new Brackets(qbbb => qbbb
.orWhere(`"${instanceAlias}"."isSilenced" = false`)
.orWhere(`"renote"."${key}Host" IS NULL`)))));
if (me) {
// case 3: we are the author
qb.orWhere(`renote.${key}Id = :meId`);
// case 4: we are following the user
this.orFollowingUser(qb, ':meId', `renote.${key}Id`);
}
// case 5: user is the same
qb.orWhere(`renote.${key}Id = renote.userId`);
}));
};
// Set parameters only once
if (me) {
q.setParameters({ meId: me.id });
}
return this
.leftJoinInstance(q, 'note.userInstance', 'userInstance')
.andWhere(new Brackets(qb => this
// case 1: we are following the user
.orFollowingUser(qb, ':meId', 'note.userId')
// case 2: user not silenced AND instance not silenced
.orWhere(new Brackets(qbb => qbb
.andWhere(new Brackets(qbbb => qbbb
.orWhere('"userInstance"."isSilenced" = false')
.orWhere('"userInstance" IS NULL')))
.andWhere('user.isSilenced = false')))))
.setParameters({ meId: me.id });
if (!excludeAuthor) {
checkFor('user', 'note.user');
}
checkFor('replyUser', 'reply.user');
checkFor('renoteUser', 'renote.user');
// Filter for boosts
this.leftJoin(q, 'renote.reply', 'renoteReply');
this.leftJoin(q, 'renote.renote', 'renoteRenote');
q.andWhere(new Brackets(qb => this
.orIsNotRenote(qb, 'note')
.orWhere(new Brackets(qbb => {
checkForRenote(qbb, 'replyUser', 'renoteReply.user', 'renoteReplyUser');
checkForRenote(qbb, 'renoteUser', 'renoteRenote.user', 'renoteRenoteUser');
}))));
return q;
}
/**
* Left-joins an instance in to the query with a given alias and optional condition.
* These calls are de-duplicated - multiple uses of the same alias are skipped.
* Left-joins a relation into the query with a given alias and optional condition.
* These calls are de-duplicated - multiple uses of the same relation+alias are skipped.
*/
@bindThis
public leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
public leftJoin<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string, alias: string, condition?: string): SelectQueryBuilder<E> {
// Skip if it's already joined, otherwise we'll get an error
if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) {
const join = q.expressionMap.joinAttributes.find(j => j.alias.name === alias);
if (join) {
const oldRelation = typeof(join.entityOrProperty) === 'function'
? join.entityOrProperty.name
: join.entityOrProperty;
const oldQuery = join.condition
? `JOIN ${oldRelation} AS ${alias} ON ${join.condition}`
: `JOIN ${oldRelation} AS ${alias}`;
const newQuery = condition
? `JOIN ${relation} AS ${alias} ON ${oldRelation}`
: `JOIN ${relation} AS ${alias}`;
if (oldRelation !== relation) {
throw new Error(`Query error: cannot add ${newQuery}: alias already used by ${oldQuery}`);
}
if (join.condition !== condition) {
throw new Error(`Query error: cannot add ${newQuery}: relation already defined with different condition by ${oldQuery}`);
}
} else {
q.leftJoin(relation, alias, condition);
}
@ -375,27 +518,33 @@ export class QueryService {
/**
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
* If withReplies is set to a boolean, then this method will only count followings with the matching withReplies value.
*/
@bindThis
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, withReplies?: boolean): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere', withReplies);
}
/**
* Adds AND condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
* If withReplies is set to a boolean, then this method will only count followings with the matching withReplies value.
*/
@bindThis
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, withReplies?: boolean): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere', withReplies);
}
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere', withReplies?: boolean): Q {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('1')
.andWhere(`following.followerId = ${followerProp}`)
.andWhere(`following.followeeId = ${followeeProp}`);
if (withReplies !== undefined) {
followingQuery.andWhere('following.withReplies = :withReplies', { withReplies });
}
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
};
@ -560,14 +709,48 @@ export class QueryService {
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('1')
.andWhere(`threadMuted.userId = ${muterProp}`)
.andWhere(`threadMuted.threadId = ${muteeProp}`);
.andWhere(`threadMuted.threadId = ${muteeProp}`)
.andWhere('threadMuted.isPostMute = false');
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
}
// Requirements: user replyUser renoteUser must be joined
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
public orNotMutingNote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingNote(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingNote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingNote(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingNote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('1')
.andWhere(`threadMuted.userId = ${muterProp}`)
.andWhere(`threadMuted.threadId = ${muteeProp}`)
.andWhere('threadMuted.isPostMute = true');
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
}
@bindThis
public generateSuspendedUserQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): void {
this.leftJoin(q, 'note.user', 'user');
this.leftJoin(q, 'note.reply', 'reply');
this.leftJoin(q, 'note.renote', 'renote');
this.leftJoin(q, 'reply.user', 'replyUser');
this.leftJoin(q, 'renote.user', 'renoteUser');
if (excludeAuthor) {
const brakets = (user: string) => new Brackets(qb => qb
.where(`note.${user}Id IS NULL`)

View file

@ -31,6 +31,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import type { DataSource } from 'typeorm';
const FALLBACK = '\u2764';
@ -108,6 +109,7 @@ export class ReactionService {
private notificationService: NotificationService,
private perUserReactionsChart: PerUserReactionsChart,
private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
}
@ -122,7 +124,8 @@ export class ReactionService {
}
// check visibility
if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) {
const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(note, user);
if (!accessible) {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}

View file

@ -314,6 +314,7 @@ export class SearchService {
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
@ -392,6 +393,7 @@ export class SearchService {
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;

View file

@ -127,6 +127,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
renoteUserInstance: null,
updatedAt: null,
processErrors: [],
mandatoryCW: null,
...override,
};
}
@ -400,6 +401,7 @@ export class WebhookTestService {
text: note.text,
cw: note.cw,
userId: note.userId,
userHost: note.userHost ?? null,
user: await this.toPackedUserLite(note.user ?? generateDummyUser()),
replyId: note.replyId,
renoteId: note.renoteId,
@ -408,6 +410,7 @@ export class WebhookTestService {
isMutingNote: false,
isFavorited: false,
isRenoted: false,
bypassSilence: false,
visibility: note.visibility,
mentions: note.mentions,
visibleUserIds: note.visibleUserIds,
@ -450,6 +453,8 @@ export class WebhookTestService {
username: user.username,
host: user.host,
description: 'dummy user',
isSilenced: false,
bypassSilence: false,
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({

View file

@ -38,6 +38,7 @@ import FederationChart from '@/core/chart/charts/federation.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
import { CacheService } from '@/core/CacheService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
@ -100,6 +101,7 @@ export class ApInboxService {
private readonly federationChart: FederationChart,
private readonly updateInstanceQueue: UpdateInstanceQueue,
private readonly cacheService: CacheService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
this.logger = this.apLoggerService.logger;
}
@ -367,7 +369,8 @@ export class ApInboxService {
const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) });
if (renote == null) return 'announce target is null';
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) {
const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(renote, actor);
if (!accessible) {
return 'skip: invalid actor for this activity';
}

View file

@ -34,6 +34,7 @@ import { QueryService } from '@/core/QueryService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { CacheService } from '@/core/CacheService.js';
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@ -75,9 +76,10 @@ export class ApRendererService {
private apMfmService: ApMfmService,
private mfmService: MfmService,
private idService: IdService,
private readonly queryService: QueryService,
private utilityService: UtilityService,
private readonly queryService: QueryService,
private readonly cacheService: CacheService,
private readonly federatedInstanceService: FederatedInstanceService,
) {
}
@ -398,6 +400,8 @@ export class ApRendererService {
return ids.map(id => items.find(item => item.id === id)).filter(x => x != null);
};
const instance = author.instance ?? (author.host ? await this.federatedInstanceService.fetch(author.host) : null);
let inReplyTo;
let inReplyToNote: MiNote | null;
@ -497,9 +501,15 @@ export class ApRendererService {
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (note.mandatoryCW) {
summary = appendContentWarning(summary, note.mandatoryCW);
}
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}
if (instance?.mandatoryCW) {
summary = appendContentWarning(summary, instance.mandatoryCW);
}
const { content } = this.apMfmService.getNoteHtml(note, apAppend);

View file

@ -75,7 +75,7 @@ export class ApImageService {
const shouldBeCached = this.meta.cacheRemoteFiles && (this.meta.cacheRemoteSensitiveFiles || !image.sensitive);
await this.federatedInstanceService.fetchOrRegister(actor.host).then(async i => {
if (i.isNSFW) {
if (i.isMediaSilenced) {
image.sensitive = true;
}
});

View file

@ -63,11 +63,11 @@ export class InstanceEntityService {
themeColor: instance.themeColor,
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
isNSFW: instance.isNSFW,
rejectReports: instance.rejectReports,
rejectQuotes: instance.rejectQuotes,
moderationNote: iAmModerator ? instance.moderationNote : null,
isBubbled: this.utilityService.isBubbledHost(instance.host),
mandatoryCW: instance.mandatoryCW,
};
}

View file

@ -17,8 +17,10 @@ import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { QueryService } from '@/core/QueryService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { Config } from '@/config.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import type { PopulatedNote } from '@/core/NoteVisibilityService.js';
import type { NoteVisibilityData } from '@/core/NoteVisibilityService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CacheService } from '../CacheService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@ -101,6 +103,9 @@ export class NoteEntityService implements OnModuleInit {
@Inject(DI.noteFavoritesRepository)
private noteFavoritesRepository: NoteFavoritesRepository,
// This is public to avoid weaving a whole new service through the Channel class hierarchy.
public readonly noteVisibilityService: NoteVisibilityService,
private readonly queryService: QueryService,
//private userEntityService: UserEntityService,
//private driveFileEntityService: DriveFileEntityService,
@ -121,6 +126,8 @@ export class NoteEntityService implements OnModuleInit {
this.idService = this.moduleRef.get('IdService');
}
// Implementation moved to NoteVisibilityService
/*
@bindThis
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
@ -136,104 +143,29 @@ export class NoteEntityService implements OnModuleInit {
}
return packedNote.visibility;
}
*/
@bindThis
public async hideNotes(notes: Packed<'Note'>[], meId: string | null): Promise<void> {
const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>();
const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set<string>();
public async hideNotes(notes: Packed<'Note'>[], meId: string | null, hint?: Partial<NoteVisibilityData>): Promise<void> {
const me = meId ? await this.cacheService.findUserById(meId) : null;
const data = await this.noteVisibilityService.populateData(me, hint);
// This shouldn't actually await, but we have to wrap it anyway because hideNote() is async
await Promise.all(notes.map(note => this.hideNote(note, meId, {
myFollowing,
myBlockers,
})));
for (const note of notes) {
await this.hideNoteAsync(note, me, data);
}
}
@bindThis
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
myFollowing?: ReadonlyMap<string, Omit<MiFollowing, 'isFollowerHibernated'>> | ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
}): Promise<void> {
if (meId === packedNote.userId) return;
public async hideNoteAsync(packedNote: Packed<'Note'>, me: string | Pick<MiUser, 'id' | 'host'> | null, hint?: Partial<NoteVisibilityData>): Promise<void> {
const { redact } = await this.noteVisibilityService.checkNoteVisibilityAsync(packedNote, me, { hint });
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
let hide = false;
if (packedNote.user.requireSigninToViewContents && meId == null) {
hide = true;
if (redact) {
this.redactNoteContents(packedNote);
}
}
if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null)
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
hide = true;
}
}
// visibility が specified かつ自分が指定されていなかったら非表示
if (!hide) {
if (packedNote.visibility === 'specified') {
if (meId == null) {
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (!specified) {
hide = true;
}
}
}
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (!hide) {
if (packedNote.visibility === 'followers') {
if (meId == null) {
hide = true;
} else if (meId === packedNote.userId) {
hide = false;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ
hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション
hide = false;
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
hide = false;
} else {
const isFollowing = hint?.myFollowing
? hint.myFollowing.has(packedNote.userId)
: (await this.cacheService.userFollowingsCache.fetch(meId)).has(packedNote.userId);
hide = !isFollowing;
}
}
}
// If this is a pure renote (boost), then we should *also* check the boosted note's visibility.
// Otherwise we can have empty notes on the timeline, which is not good.
// Notes are packed in depth-first order, so we can safely grab the "isHidden" property to avoid duplicated checks.
// This is pulled out to ensure that we check both the renote *and* the boosted note.
if (packedNote.renote?.isHidden && isPackedPureRenote(packedNote)) {
hide = true;
}
if (!hide && meId && packedNote.userId !== meId) {
const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId);
const isBlocked = blockers.has(packedNote.userId);
if (isBlocked) hide = true;
}
if (hide) {
private redactNoteContents(packedNote: Packed<'Note'>) {
{
packedNote.visibleUserIds = undefined;
packedNote.fileIds = [];
packedNote.files = [];
@ -477,74 +409,83 @@ export class NoteEntityService implements OnModuleInit {
return undefined;
}
// Implementation moved to NoteVisibilityService
/*
@bindThis
public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null, hint?: {
myFollowing?: ReadonlySet<string>,
myBlocking?: ReadonlySet<string>,
myBlockers?: ReadonlySet<string>,
me?: Pick<MiUser, 'host'> | null,
me?: Pick<MiUser, 'id' | 'host'> | null,
}): Promise<boolean> {
const [myFollowings, myBlockers, me] = await Promise.all([
hint?.myFollowing ?? (meId ? this.cacheService.userFollowingsCache.fetch(meId).then(fs => new Set(fs.keys())) : null),
hint?.myBlockers ?? (meId ? this.cacheService.userBlockedCache.fetch(meId) : null),
hint?.me ?? (meId ? this.cacheService.findUserById(meId) : null),
]);
return this.isVisibleForMeSync(note, me, myFollowings, myBlockers);
}
@bindThis
public isVisibleForMeSync(note: MiNote | Packed<'Note'>, me: Pick<MiUser, 'id' | 'host'> | null, myFollowings: ReadonlySet<string> | null, myBlockers: ReadonlySet<string> | null): boolean {
// We can always view our own notes
if (me?.id === note.userId) {
return true;
}
// We can *never* view blocked notes
if (myBlockers?.has(note.userId)) {
return false;
}
// This code must always be synchronized with the checks in generateVisibilityQuery.
// visibility が specified かつ自分が指定されていなかったら非表示
if (note.visibility === 'specified') {
if (meId == null) {
if (me == null) {
return false;
} else if (!note.visibleUserIds) {
return false;
} else if (meId === note.userId) {
return true;
} else {
// 指定されているかどうか
return note.visibleUserIds.some(id => meId === id);
return note.visibleUserIds.includes(me.id);
}
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (note.visibility === 'followers') {
if (meId == null) {
if (me == null) {
return false;
} else if (meId === note.userId) {
return true;
} else if (note.reply && (meId === note.reply.userId)) {
} else if (note.reply && (me.id === note.reply.userId)) {
// 自分の投稿に対するリプライ
return true;
} else if (note.mentions && note.mentions.some(id => meId === id)) {
} else if (!note.mentions) {
return false;
} else if (note.mentions.includes(me.id)) {
// 自分へのメンション
return true;
} else if (!note.visibleUserIds) {
return false;
} else if (note.visibleUserIds.includes(me.id)) {
// Explicitly visible to me
return true;
} else {
// フォロワーかどうか
const [blocked, following, userHost] = await Promise.all([
hint?.myBlocking
? hint.myBlocking.has(note.userId)
: this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
hint?.myFollowing
? hint.myFollowing.has(note.userId)
: this.cacheService.userFollowingsCache.fetch(meId).then(ids => ids.has(note.userId)),
hint?.me !== undefined
? (hint.me?.host ?? null)
: this.cacheService.findUserById(meId).then(me => me.host),
]);
const following = myFollowings?.has(note.userId);
const userHost = me.host;
if (blocked) return false;
/* If we know the following, everyhting is fine.
But if we do not know the following, it might be that both the
author of the note and the author of the like are remote users,
in which case we can never know the following. Instead we have
to assume that the users are following each other.
*/
// If we know the following, everyhting is fine.
//
// But if we do not know the following, it might be that both the
// author of the note and the author of the like are remote users,
// in which case we can never know the following. Instead we have
// to assume that the users are following each other.
return following || (note.userHost != null && userHost != null);
}
}
if (meId != null) {
const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId);
const isBlocked = blockers.has(note.userId);
if (isBlocked) return false;
}
return true;
}
*/
@bindThis
public async packAttachedFiles(fileIds: MiNote['fileIds'], packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
@ -569,6 +510,7 @@ export class NoteEntityService implements OnModuleInit {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
bypassSilence?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
@ -642,15 +584,19 @@ export class NoteEntityService implements OnModuleInit {
.getExists() : false),
]);
const bypassSilence = opts.bypassSilence || note.userId === meId;
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
threadId,
createdAt: this.idService.parse(note.id).date.toISOString(),
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
userId: note.userId,
userHost: note.userHost,
user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me),
text: text,
cw: note.cw,
mandatoryCW: note.mandatoryCW,
visibility: note.visibility,
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
@ -688,6 +634,7 @@ export class NoteEntityService implements OnModuleInit {
isMutingNote: mutedNotes.has(note.id),
isFavorited,
isRenoted,
bypassSilence,
...(meId && Object.keys(reactions).length > 0 ? {
myReaction: this.populateMyReaction({
@ -706,6 +653,9 @@ export class NoteEntityService implements OnModuleInit {
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
// Don't silence target of self-reply, since the outer note will already be silenced.
bypassSilence: bypassSilence || note.userId === note.replyUserId,
}) : undefined,
renote: note.renoteId ? this.pack(note.renote ?? opts._hint_?.notes.get(note.renoteId) ?? note.renoteId, me, {
@ -713,16 +663,21 @@ export class NoteEntityService implements OnModuleInit {
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
// Don't silence target of self-renote, since the outer note will already be silenced.
bypassSilence: bypassSilence || note.userId === note.renoteUserId,
}) : undefined,
} : {}),
});
this.treatVisibility(packed);
this.noteVisibilityService.syncVisibility(packed);
if (!opts.skipHide) {
await this.hideNote(packed, meId, meId == null ? undefined : {
myFollowing: opts._hint_?.userFollowings.get(meId),
myBlockers: opts._hint_?.userBlockers.get(meId),
await this.hideNoteAsync(packed, meId, {
userFollowings: meId ? opts._hint_?.userFollowings.get(meId) : null,
userBlockers: meId ? opts._hint_?.userBlockers.get(meId) : null,
userMutedNotes: opts._hint_?.mutedNotes,
userMutedThreads: opts._hint_?.mutedThreads,
});
}
@ -736,79 +691,13 @@ export class NoteEntityService implements OnModuleInit {
options?: {
detail?: boolean;
skipHide?: boolean;
bypassSilence?: boolean;
},
) {
if (notes.length === 0) return [];
const targetNotesMap = new Map<string, MiNote>();
const targetNotesToFetch : string[] = [];
for (const note of notes) {
if (isPureRenote(note)) {
// we may need to fetch 'my reaction' for renote target.
if (note.renote) {
targetNotesMap.set(note.renote.id, note.renote);
if (note.renote.reply) {
// idem if the renote is also a reply.
targetNotesMap.set(note.renote.reply.id, note.renote.reply);
}
} else if (options?.detail) {
targetNotesToFetch.push(note.renoteId);
}
} else {
if (note.reply) {
// idem for OP of a regular reply.
targetNotesMap.set(note.reply.id, note.reply);
} else if (note.replyId && options?.detail) {
targetNotesToFetch.push(note.replyId);
}
targetNotesMap.set(note.id, note);
}
}
// Don't fetch notes that were added by ID and then found inline in another note.
for (let i = targetNotesToFetch.length - 1; i >= 0; i--) {
if (targetNotesMap.has(targetNotesToFetch[i])) {
targetNotesToFetch.splice(i, 1);
}
}
// Populate any relations that weren't included in the source
if (targetNotesToFetch.length > 0) {
const newNotes = await this.notesRepository.find({
where: {
id: In(targetNotesToFetch),
},
relations: {
user: {
userProfile: true,
},
reply: {
user: {
userProfile: true,
},
},
renote: {
user: {
userProfile: true,
},
reply: {
user: {
userProfile: true,
},
},
},
channel: true,
},
});
for (const note of newNotes) {
targetNotesMap.set(note.id, note);
}
}
const targetNotes = Array.from(targetNotesMap.values());
const noteIds = Array.from(targetNotesMap.keys());
const targetNotes = await this.fetchRequiredNotes(notes, options?.detail ?? false);
const noteIds = Array.from(new Set(targetNotes.map(n => n.id)));
const usersMap = new Map<string, MiUser | string>();
const allUsers = notes.flatMap(note => [
@ -915,6 +804,84 @@ export class NoteEntityService implements OnModuleInit {
})));
}
// TODO find a way to de-duplicate pack() calls when we have multiple references to the same note.
private async fetchRequiredNotes(notes: MiNote[], detail: boolean): Promise<MiNote[]> {
const notesMap = new Map<string, MiNote>();
const notesToFetch = new Set<string>();
function addNote(note: string | MiNote | null | undefined) {
if (note == null) return;
if (typeof(note) === 'object') {
notesMap.set(note.id, note);
notesToFetch.delete(note.id);
} else if (detail) {
if (!notesMap.has(note)) {
notesToFetch.add(note);
}
}
}
// Enumerate 1st-tier dependencies
for (const note of notes) {
// Add note itself
addNote(note);
// Add renote
if (note.renoteId) {
if (note.renote) {
addNote(note.renote);
addNote(note.renote.reply ?? note.renote.replyId);
addNote(note.renote.renote ?? note.renote.renoteId);
} else {
addNote(note.renoteId);
}
}
// Add reply
addNote(note.reply ?? note.replyId);
}
// Populate 1st-tier dependencies
if (notesToFetch.size > 0) {
const newNotes = await this.notesRepository.find({
where: {
id: In(Array.from(notesToFetch)),
},
relations: {
reply: true,
renote: {
reply: true,
renote: true,
},
channel: true,
},
});
for (const note of newNotes) {
addNote(note);
}
notesToFetch.clear();
}
// Extract second-tier dependencies
for (const note of Array.from(notesMap.values())) {
if (isPureRenote(note) && note.renote) {
if (note.renote.reply && !notesMap.has(note.renote.reply.id)) {
notesMap.set(note.renote.reply.id, note.renote.reply);
}
if (note.renote.renote && !notesMap.has(note.renote.renote.id)) {
notesMap.set(note.renote.renote.id, note.renote.renote);
}
}
}
return Array.from(notesMap.values());
}
@bindThis
public aggregateNoteEmojis(notes: MiNote[]) {
let emojis: { name: string | null; host: string | null; }[] = [];

View file

@ -183,7 +183,7 @@ export class UserEntityService implements OnModuleInit {
public isRemoteUser = isRemoteUser;
@bindThis
public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise<UserRelation> {
public async getRelation(me: MiUser['id'], target: MiUser['id'], hint?: { myFollowings?: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> }): Promise<UserRelation> {
const [
following,
isFollowed,
@ -197,7 +197,9 @@ export class UserEntityService implements OnModuleInit {
memo,
mutedInstances,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null),
hint?.myFollowings
? (hint.myFollowings.get(target) ?? null)
: this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null),
this.cacheService.userFollowingsCache.fetch(target).then(f => f.has(me)),
this.followRequestsRepository.exists({
where: {
@ -248,7 +250,8 @@ export class UserEntityService implements OnModuleInit {
}
@bindThis
public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise<Map<MiUser['id'], UserRelation>> {
public async getRelations(me: MiUser['id'], targets: MiUser['id'][], hint?: { myFollowings?: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> }): Promise<Map<MiUser['id'], UserRelation>> {
// noinspection ES6MissingAwait
const [
myFollowing,
myFollowers,
@ -262,7 +265,7 @@ export class UserEntityService implements OnModuleInit {
memos,
mutedInstances,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me),
hint?.myFollowings ?? this.cacheService.userFollowingsCache.fetch(me),
this.cacheService.userFollowersCache.fetch(me),
this.followRequestsRepository.createQueryBuilder('f')
.select('f.followeeId')
@ -432,6 +435,7 @@ export class UserEntityService implements OnModuleInit {
userIdsByUri?: Map<string, string>,
instances?: Map<string, MiInstance | null>,
securityKeyCounts?: Map<string, number>,
myFollowings?: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>,
},
): Promise<Packed<S>> {
const opts = Object.assign({
@ -479,12 +483,14 @@ export class UserEntityService implements OnModuleInit {
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
: null;
const myFollowings = opts.myFollowings ?? (meId ? await this.cacheService.userFollowingsCache.fetch(meId) : undefined);
let relation: UserRelation | null = null;
if (meId && !isMe && isDetailed) {
if (opts.userRelations) {
relation = opts.userRelations.get(user.id) ?? null;
} else {
relation = await this.getRelation(meId, user.id);
relation = await this.getRelation(meId, user.id, { myFollowings });
}
}
@ -537,6 +543,8 @@ export class UserEntityService implements OnModuleInit {
let fetchPoliciesPromise: Promise<RolePolicies> | null = null;
const fetchPolicies = () => fetchPoliciesPromise ??= this.roleService.getUserPolicies(user);
const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false);
const packed = {
id: user.id,
name: user.name,
@ -564,7 +572,8 @@ export class UserEntityService implements OnModuleInit {
mandatoryCW: user.mandatoryCW,
rejectQuotes: user.rejectQuotes,
attributionDomains: user.attributionDomains,
isSilenced: user.isSilenced || fetchPolicies().then(r => !r.canPublicNote),
isSilenced: user.isSilenced,
bypassSilence: bypassSilence,
speakAsCat: user.speakAsCat ?? false,
approved: user.approved,
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
@ -578,6 +587,7 @@ export class UserEntityService implements OnModuleInit {
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
isSilenced: instance.isSilenced,
mandatoryCW: instance.mandatoryCW,
} : undefined) : undefined,
followersCount: followersCount ?? 0,
followingCount: followingCount ?? 0,
@ -782,14 +792,20 @@ export class UserEntityService implements OnModuleInit {
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts] = await Promise.all([
const myFollowingsPromise: Promise<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> | undefined> = meId
? this.cacheService.userFollowingsCache.fetch(meId)
: Promise.resolve(undefined);
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, myFollowings] = await Promise.all([
// profilesMap
this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))),
// userMemos
isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId })
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(),
// userRelations
isDetailed && meId ? this.getRelations(meId, _userIds) : new Map(),
meId && isDetailed
? myFollowingsPromise.then(myFollowings => this.getRelations(meId, _userIds, { myFollowings }))
: new Map(),
// pinNotes
isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin')
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
@ -833,6 +849,8 @@ export class UserEntityService implements OnModuleInit {
.getRawMany<{ userId: string, userCount: number }>()
.then(counts => new Map(counts.map(c => [c.userId, c.userCount])))
: undefined, // .pack will fetch the keys for the requesting user if it's in the _userIds
// myFollowings
myFollowingsPromise,
]);
return Promise.all(
@ -849,6 +867,7 @@ export class UserEntityService implements OnModuleInit {
userIdsByUri,
instances,
securityKeyCounts,
myFollowings,
},
)),
);

View file

@ -23,8 +23,17 @@ export const getNoteSummary = (note: Packed<'Note'>): string => {
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.mandatoryCW) {
cw = appendContentWarning(cw, `Note is flagged: "${note.mandatoryCW}"`);
}
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
const username = note.user.host
? `@${note.user.username}@${note.user.host}`
: `@${note.user.username}`;
cw = appendContentWarning(cw, `${username} is flagged: "${note.user.mandatoryCW}"`);
}
if (note.user.instance?.mandatoryCW) {
cw = appendContentWarning(cw, `${note.user.host} is flagged: "${note.user.instance.mandatoryCW}"`);
}
// 本文

View file

@ -205,11 +205,6 @@ export class MiInstance {
})
public infoUpdatedAt: Date | null;
@Column('boolean', {
default: false,
})
public isNSFW: boolean;
@Column('boolean', {
default: false,
})
@ -228,4 +223,13 @@ export class MiInstance {
length: 16384, default: '',
})
public moderationNote: string;
/**
* Specifies a Content Warning that should be forcibly applied to all notes from this instance
* If null (default), then no Content Warning is applied.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
}

View file

@ -228,6 +228,15 @@ export class MiNote {
})
public processErrors: string[] | null;
/**
* Specifies a Content Warning that should be forcibly attached to this note.
* Does not replace the user's own CW.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
//#region Denormalized fields
@Column('varchar', {
length: 128, nullable: true,

View file

@ -116,11 +116,6 @@ export const packedFederationInstanceSchema = {
optional: false, nullable: true,
format: 'date-time',
},
isNSFW: {
type: 'boolean',
optional: false,
nullable: false,
},
rejectReports: {
type: 'boolean',
optional: false,
@ -139,5 +134,9 @@ export const packedFederationInstanceSchema = {
type: 'boolean',
optional: false, nullable: false,
},
mandatoryCW: {
type: 'string',
optional: false, nullable: true,
},
},
} as const;

View file

@ -41,11 +41,19 @@ export const packedNoteSchema = {
type: 'string',
optional: true, nullable: true,
},
mandatoryCW: {
type: 'string',
optional: true, nullable: true,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
userHost: {
type: 'string',
optional: false, nullable: true,
},
user: {
type: 'object',
ref: 'UserLite',
@ -189,6 +197,10 @@ export const packedNoteSchema = {
type: 'boolean',
optional: false, nullable: false,
},
bypassSilence: {
type: 'boolean',
optional: false, nullable: false,
},
emojis: {
type: 'object',
optional: true, nullable: false,

View file

@ -184,6 +184,10 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: false,
},
bypassSilence: {
type: 'boolean',
nullable: false, optional: false,
},
requireSigninToViewContents: {
type: 'boolean',
nullable: false, optional: true,
@ -228,6 +232,10 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: false,
},
mandatoryCW: {
type: 'string',
nullable: true, optional: false,
},
},
},
followersCount: {

View file

@ -34,6 +34,8 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor
export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js';
export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js';
export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js';
export * as 'admin/cw-instance' from './endpoints/admin/cw-instance.js';
export * as 'admin/cw-note' from './endpoints/admin/cw-note.js';
export * as 'admin/cw-user' from './endpoints/admin/cw-user.js';
export * as 'admin/decline-user' from './endpoints/admin/decline-user.js';
export * as 'admin/delete-account' from './endpoints/admin/delete-account.js';

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-instance',
res: {},
} as const;
export const paramDef = {
type: 'object',
properties: {
host: { type: 'string' },
cw: { type: 'string', nullable: true },
},
required: ['host', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private readonly moderationLogService: ModerationLogService,
private readonly federatedInstanceService: FederatedInstanceService,
) {
super(meta, paramDef, async (ps, me) => {
const instance = await this.federatedInstanceService.fetchOrRegister(ps.host);
// Collapse empty strings to null
const newCW = ps.cw?.trim() || null;
const oldCW = instance.mandatoryCW;
// Skip if there's nothing to do
if (oldCW === newCW) return;
// This synchronizes caches automatically
await this.federatedInstanceService.update(instance.id, { mandatoryCW: newCW });
await this.moderationLogService.log(me, 'setMandatoryCWForInstance', {
newCW,
oldCW,
host: ps.host,
});
});
}
}

View file

@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, DriveFilesRepository, MiNote, NotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { NoteEditService, Option } from '@/core/NoteEditService.js';
import { CacheService } from '@/core/CacheService.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-note',
res: {},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['noteId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
@Inject(DI.driveFilesRepository)
private readonly driveFilesRepository: DriveFilesRepository,
@Inject(DI.channelsRepository)
private readonly channelsRepository: ChannelsRepository,
private readonly noteEditService: NoteEditService,
private readonly moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.notesRepository.findOneOrFail({
where: { id: ps.noteId },
relations: { reply: true, renote: true, channel: true },
});
const user = await this.cacheService.findUserById(note.userId);
// Collapse empty strings to null
const newCW = ps.cw?.trim() || null;
const oldCW = note.mandatoryCW;
// Skip if there's nothing to do
if (oldCW === newCW) return;
// TODO remove this after merging hazelnoot/fix-note-edit-logic.
// Until then, we have to ensure that everything is populated just like it would be from notes/edit.ts.
// Otherwise forcing a CW will erase everything else in the note.
// After merging remove all the "createUpdate" stuff and just pass "{ mandatoryCW: newCW }" into noteEditService.edit().
const update = await this.createUpdate(note, newCW);
await this.noteEditService.edit(user, note.id, update);
await this.moderationLogService.log(me, 'setMandatoryCWForNote', {
newCW,
oldCW,
noteId: note.id,
noteUserId: user.id,
noteUserUsername: user.username,
noteUserHost: user.host,
});
});
}
// Note: user must be fetched with reply, renote, and channel relations populated
private async createUpdate(note: MiNote, newCW: string | null) {
// This is based on the call to NoteEditService.edit from notes/edit endpoint.
// noinspection ES6MissingAwait
return await awaitAll<Option>({
// Preserve these from original note
files: note.fileIds.length > 0
? this.driveFilesRepository.findBy({ id: In(note.fileIds) }) : null,
poll: undefined,
text: undefined,
cw: undefined,
reply: note.reply
?? (note.replyId ? this.notesRepository.findOneByOrFail({ id: note.replyId }) : null),
renote: note.renote
?? (note.renoteId ? this.notesRepository.findOneByOrFail({ id: note.renoteId }) : null),
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
visibility: note.visibility,
visibleUsers: note.visibleUserIds.length > 0
? this.cacheService.getUsers(note.visibleUserIds).then(us => Array.from(us.values())) : null,
channel: note.channel ?? (note.channelId ? this.channelsRepository.findOneByOrFail({ id: note.channelId }) : null),
apMentions: undefined,
apHashtags: undefined,
apEmojis: undefined,
// But override the mandatory CW!
mandatoryCW: newCW,
});
}
}

View file

@ -17,6 +17,8 @@ export const meta = {
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-user',
res: {},
} as const;
export const paramDef = {
@ -41,21 +43,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const user = await this.cacheService.findUserById(ps.userId);
// Skip if there's nothing to do
if (user.mandatoryCW === ps.cw) return;
// Collapse empty strings to null
const newCW = ps.cw?.trim() || null;
const oldCW = user.mandatoryCW;
await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});
// Skip if there's nothing to do
if (oldCW === newCW) return;
await this.usersRepository.update(ps.userId, { mandatoryCW: newCW });
// Synchronize caches and other processes
this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
const evt = user.host == null ? 'localUserUpdated' : 'remoteUserUpdated';
this.globalEventService.publishInternalEvent(evt, { id: ps.userId });
await this.moderationLogService.log(me, 'setMandatoryCW', {
newCW: ps.cw,
oldCW: user.mandatoryCW,
newCW,
oldCW,
userId: user.id,
userUsername: user.username,
userHost: user.host,

View file

@ -24,7 +24,6 @@ export const paramDef = {
properties: {
host: { type: 'string' },
isSuspended: { type: 'boolean' },
isNSFW: { type: 'boolean' },
rejectReports: { type: 'boolean' },
moderationNote: { type: 'string' },
rejectQuotes: { type: 'boolean' },
@ -58,7 +57,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.federatedInstanceService.update(instance.id, {
suspensionState,
isNSFW: ps.isNSFW,
rejectReports: ps.rejectReports,
rejectQuotes: ps.rejectQuotes,
moderationNote: ps.moderationNote,
@ -78,14 +76,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
if (ps.isNSFW != null && instance.isNSFW !== ps.isNSFW) {
const message = ps.rejectReports ? 'setRemoteInstanceNSFW' : 'unsetRemoteInstanceNSFW';
this.moderationLogService.log(me, message, {
id: instance.id,
host: instance.host,
});
}
if (ps.rejectReports != null && instance.rejectReports !== ps.rejectReports) {
const message = ps.rejectReports ? 'rejectRemoteInstanceReports' : 'acceptRemoteInstanceReports';
this.moderationLogService.log(me, message, {

View file

@ -122,6 +122,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);

View file

@ -107,8 +107,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
}
const threadMutings = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null;
return await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -122,13 +120,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
dbFallback: async (untilId, sinceId, limit) => {
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me);
},
noteFilter: note => {
if (threadMutings?.has(note.threadId ?? note.id)) {
return false;
}
return true;
},
});
});
}

View file

@ -71,10 +71,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const isModerator = await this.roleService.isModerator(me);
// Fetch file
const file = await this.driveFilesRepository.findOneBy({
id: ps.fileId,
userId: await this.roleService.isModerator(me) ? undefined : me.id,
userId: isModerator ? undefined : me.id,
});
if (file == null) {
@ -90,16 +92,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
if (!isModerator) {
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me, {
detail: true,
skipHide: isModerator,
});
});
}

View file

@ -9,6 +9,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
tags: ['notes'],
@ -23,6 +25,19 @@ export const meta = {
},
},
errors: {
gtlDisabled: {
message: 'Global timeline has been disabled.',
code: 'GTL_DISABLED',
id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b',
},
ltlDisabled: {
message: 'Local timeline has been disabled.',
code: 'LTL_DISABLED',
id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
},
},
// 120 calls per minute
// 200 ms between calls
limit: {
@ -55,8 +70,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
if (!ps.local && !policies.gtlAvailable) {
throw new ApiError(meta.errors.gtlDisabled);
}
if (ps.local && !policies.ltlAvailable) {
throw new ApiError(meta.errors.ltlDisabled);
}
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.visibility = \'public\'')
.andWhere('note.localOnly = FALSE')
@ -68,7 +92,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
@ -77,10 +100,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.local) {
query.andWhere('note.userHost IS NULL');
} else {
this.queryService.generateBlockedHostQueryForNote(query);
}
if (ps.reply !== undefined) {
query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL');
if (ps.reply) {
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
} else if (ps.reply === false) {
query.andWhere('note.replyId IS NULL');
}
if (ps.renote !== undefined) {

View file

@ -93,9 +93,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoin('(select "host" from "instance" where "isBubbled" = true)', 'bubbleInstance', '"bubbleInstance"."host" = "note"."userHost"')
.andWhere('"bubbleInstance" IS NOT NULL');
this.queryService
.leftJoinInstance(query, 'note.userInstance', 'userInstance', '"userInstance"."host" = "bubbleInstance"."host"');
.leftJoin(query, 'note.userInstance', 'userInstance');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);

View file

@ -26,10 +26,11 @@ export const meta = {
},
},
// 10 calls per 5 seconds
// Up to 25 calls, then 4 / second
limit: {
duration: 1000 * 5,
max: 10,
type: 'bucket',
size: 25,
dripRate: 250,
},
} as const;

View file

@ -18,6 +18,7 @@ import { NoteCreateService } from '@/core/NoteCreateService.js';
import { DI } from '@/di-symbols.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -261,6 +262,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.text && ps.text.length > this.config.maxNoteLength) {
@ -303,7 +305,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (isRenote(renote) && !isQuote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
} else if (!await this.noteEntityService.isVisibleForMe(renote, me.id)) {
} else if (!(await this.noteVisibilityService.checkNoteVisibilityAsync(renote, me)).accessible) {
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
}
@ -351,7 +353,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
} else if (!(await this.noteVisibilityService.checkNoteVisibilityAsync(reply, me)).accessible) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -17,6 +17,7 @@ import { NoteEditService } from '@/core/NoteEditService.js';
import { DI } from '@/di-symbols.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -311,6 +312,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private noteEditService: NoteEditService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.text && ps.text.length > this.config.maxNoteLength) {
@ -408,7 +410,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id, { me })) {
} else if (!(await this.noteVisibilityService.checkNoteVisibilityAsync(reply, me)).accessible) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);

View file

@ -102,14 +102,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
const [
userIdsWhoMeMuting,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
@ -120,15 +112,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel')
.andWhere('user.isExplorable = TRUE');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return true;
});
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);

View file

@ -150,11 +150,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Hide blocked users / instances
query.andWhere('"user"."isSuspended" = false');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me, ps.list !== 'followers');
this.queryService.generateSuspendedUserQueryForNote(query);
// Respect blocks, mutes, and privacy
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, ps.list !== 'followers');
// Support pagination
this.queryService

View file

@ -87,7 +87,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);

View file

@ -145,14 +145,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
];
}
const [
followings,
mutedThreads,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.threadMutingsCache.fetch(me.id),
]);
const redisTimeline = await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -161,20 +153,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me,
redisTimelines: timelineConfig,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false;
}
if (mutedThreads.has(note.threadId ?? note.id)) {
return false;
}
return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
@ -204,14 +184,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withRenotes: boolean,
}, me: MiLocalUser) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
// 1. by a user I follow, 2. a public local post, 3. my own post
// by a user I follow OR a public local post OR my own post
.andWhere(new Brackets(qb => this.queryService
.orFollowingUser(qb, ':meId', 'note.userId')
.orWhere(new Brackets(qbb => qbb
.andWhere('note.visibility = \'public\'')
.andWhere('note.userHost IS NULL')))
.orWhere(':meId = note.userId')))
// 1. in a channel I follow, 2. not in a channel
// in a channel I follow OR not in a channel
.andWhere(new Brackets(qb => this.queryService
.orFollowingChannel(qb, ':meId', 'note.channelId')
.orWhere('note.channelId IS NULL')))
@ -224,11 +204,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit);
if (!ps.withReplies) {
query
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')));
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
}
this.queryService.generateVisibilityQuery(query, me);

View file

@ -117,8 +117,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.noteEntityService.packMany(timeline, me);
}
const mutedThreads = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null;
const timeline = await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -131,7 +129,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
: ps.withReplies ? ['localTimeline', 'localTimelineWithReplies']
: me ? ['localTimeline', `localTimelineWithReplyTo:${me.id}`]
: ['localTimeline'],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
@ -143,13 +140,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me),
noteFilter: note => {
if (mutedThreads?.has(note.threadId ?? note.id)) {
return false;
}
return true;
},
});
if (me) {
@ -184,11 +174,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit);
if (!ps.withReplies) {
query
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')));
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
}
this.queryService.generateBlockedHostQueryForNote(query);

View file

@ -87,6 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(qb, me);
this.queryService.generateBlockedHostQueryForNote(qb);
this.queryService.generateSuspendedUserQueryForNote(qb);
this.queryService.generateSilencedUserQueryForNotes(qb, me);
this.queryService.generateMutedUserQueryForNotes(qb, me);
this.queryService.generateMutedNoteThreadQuery(qb, me);
this.queryService.generateBlockedUserQueryForNotes(qb, me);

View file

@ -137,10 +137,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
//#region block/mute/vis
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
//#endregion

View file

@ -93,9 +93,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
const renotes = await query.limit(ps.limit).getMany();

View file

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (me) this.queryService.generateMutedNoteThreadQuery(query, me);
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
@ -123,19 +123,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw e;
}
if (ps.reply != null) {
if (ps.reply === false) {
query.andWhere('note.replyId IS NULL');
} else {
if (ps.reply) {
query.andWhere('note.replyId IS NOT NULL');
} else {
query.andWhere('note.replyId IS NULL');
}
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
}
if (ps.renote != null) {
if (ps.renote === false) {
this.queryService.andIsNotRenote(query, 'note');
} else {
if (ps.renote) {
this.queryService.andIsRenote(query, 'note');
} else {
this.queryService.andIsNotRenote(query, 'note');
}
if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
}

View file

@ -97,14 +97,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.noteEntityService.packMany(timeline, me);
}
const [
followings,
threadMutings,
] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
this.cacheService.threadMutingsCache.fetch(me.id),
]);
const timeline = this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -113,18 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
noteFilter: note => {
if (note.reply && note.reply.visibility === 'followers') {
if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false;
}
if (!ps.withBots && note.user?.isBot) return false;
if (threadMutings.has(note.threadId ?? note.id)) return false;
return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
untilId,
sinceId,
@ -146,7 +127,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
// 1. in a channel I follow, 2. my own post, 3. by a user I follow
// in a channel I follow OR my own post OR by a user I follow
.andWhere(new Brackets(qb => this.queryService
.orFollowingChannel(qb, ':meId', 'note.channelId')
.orWhere(':meId = note.userId')
@ -154,10 +135,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andFollowingUser(qb2, ':meId', 'note.userId')
.andWhere('note.channelId IS NULL'))),
))
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
@ -166,6 +143,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);

View file

@ -15,6 +15,7 @@ import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { hasText } from '@/models/Note.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { NoteVisibilityService } from '@/core/NoteVisibilityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -84,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
private readonly cacheService: CacheService,
private readonly loggerService: ApiLoggerService,
private readonly noteVisibilityService: NoteVisibilityService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
@ -91,7 +93,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null, { me }))) {
const { accessible } = await this.noteVisibilityService.checkNoteVisibilityAsync(note, me);
if (!accessible) {
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}

View file

@ -125,8 +125,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me,
useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback,
redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`],
alwaysIncludeMyNotes: true,
excludePureRenotes: !ps.withRenotes,
ignoreAuthorFromUserSilence: true,
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, {
untilId,
sinceId,
@ -156,16 +156,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => qb
// 返信ではない
.orWhere('note.replyId IS NULL')
// 返信だけど投稿者自身への返信
.orWhere('note.replyUserId = note.userId')
// 返信だけど自分宛ての返信
.orWhere('note.replyUserId = :meId')
// 返信だけどwithRepliesがtrueの場合
.orWhere('userListMemberships.withReplies = true'),
))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
@ -174,10 +164,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateExcludedRepliesQueryForNotes(query, me, 'userListMemberships.withReplies');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
this.queryService.generateMutedUserQueryForNotes(query, me, true);
this.queryService.generateBlockedUserQueryForNotes(query, me);
if (ps.withFiles) {

View file

@ -110,6 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);

View file

@ -78,12 +78,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
const [
userIdsWhoMeMuting,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
@ -95,13 +89,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
if (me) {
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, true);
}
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
return true;
});
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);

View file

@ -134,8 +134,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withReplies) redisTimelines.push(`userTimelineWithReplies:${ps.userId}`);
if (ps.withChannelNotes) redisTimelines.push(`userTimelineWithChannel:${ps.userId}`);
const isFollowing = me && (await this.cacheService.userFollowingsCache.fetch(me.id)).has(ps.userId);
const timeline = await this.fanoutTimelineEndpointService.timeline({
untilId,
sinceId,
@ -147,14 +145,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
ignoreAuthorFromMute: true,
ignoreAuthorFromInstanceBlock: true,
ignoreAuthorFromUserSuspension: true,
ignoreAuthorFromUserSilence: true,
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
noteFilter: note => {
if (note.channel?.isSensitive && !isSelf) return false;
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
// These are handled by DB fallback, but we duplicate them here in case a timeline was already populated with notes
if (!ps.withRepliesToSelf && note.reply?.userId === note.userId) return false;
@ -218,12 +215,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('note.channelId IS NULL');
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query, true);
this.queryService.generateSuspendedUserQueryForNote(query, true);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
this.queryService.generateMutedUserQueryForNotes(query, me, true);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
}
if (ps.withFiles) {
@ -241,13 +239,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
query.andWhere('reply.id IS NULL');
} else if (!ps.withRepliesToOthers) {
query.andWhere('(reply.id IS NULL OR reply."userId" = note."userId")');
this.queryService.generateExcludedRepliesQueryForNotes(query, me);
} else if (!ps.withRepliesToSelf) {
query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")');
}
if (!ps.withNonPublic) {
query.andWhere('note.visibility = \'public\'');
} else {
this.queryService.generateVisibilityQuery(query, me);
}
if (!ps.withBots) {

View file

@ -100,8 +100,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set<string>();
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId })
@ -115,21 +113,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me, true);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, true);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
const reactions = (await query
.limit(ps.limit)
.getMany()).filter(reaction => {
if (reaction.note?.userId === ps.userId) return true; // we can see reactions to note of requesting user
if (me && isUserRelated(reaction.note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(reaction.note, userIdsWhoMeMuting)) return false;
return true;
});
const reactions = await query.limit(ps.limit).getMany();
return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
});

View file

@ -23,6 +23,7 @@ import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.j
import { GetterService } from '@/server/api/GetterService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { isRenote } from '@/misc/is-renote.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
// Missing from Megalodon apparently
// https://docs.joinmastodon.org/entities/StatusEdit/
@ -68,6 +69,7 @@ export class MastodonConverters {
private readonly idService: IdService,
private readonly driveFileEntityService: DriveFileEntityService,
private readonly mastodonDataService: MastodonDataService,
private readonly federatedInstanceService: FederatedInstanceService,
) {}
private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention {
@ -210,6 +212,7 @@ export class MastodonConverters {
}
const noteUser = await this.getUser(note.userId);
const noteInstance = noteUser.instance ?? (noteUser.host ? await this.federatedInstanceService.fetch(noteUser.host) : null);
const account = await this.convertAccount(noteUser);
const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } });
const history: StatusEdit[] = [];
@ -224,7 +227,16 @@ export class MastodonConverters {
// TODO avoid re-packing files for each edit
const files = await this.driveFileEntityService.packManyByIds(edit.fileIds);
const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? '';
let cw = edit.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
if (noteInstance?.mandatoryCW) {
cw = appendContentWarning(cw, noteInstance.mandatoryCW);
}
const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId);
const quoteUri = isQuote
@ -299,7 +311,13 @@ export class MastodonConverters {
? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text))
: '';
const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? '';
let cw = note.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
const reblogged = await this.mastodonDataService.hasReblog(note.id, me);

View file

@ -12,6 +12,7 @@ import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { deepClone } from '@/misc/clone.js';
import type Connection from '@/server/api/stream/Connection.js';
import { NoteVisibilityFilters } from '@/core/NoteVisibilityService.js';
/**
* Stream channel
@ -26,6 +27,10 @@ export default abstract class Channel {
public static readonly requireCredential: boolean;
public static readonly kind?: string | null;
protected get noteVisibilityService() {
return this.noteEntityService.noteVisibilityService;
}
protected get user() {
return this.connection.user;
}
@ -105,8 +110,14 @@ export default abstract class Channel {
return this.connection.myRecentFavorites;
}
protected async checkNoteVisibility(note: Packed<'Note'>, filters?: NoteVisibilityFilters) {
// Don't use any of the local cached data, because this does everything through CacheService which is just as fast with updated data.
return await this.noteVisibilityService.checkNoteVisibilityAsync(note, this.user, { filters });
}
/**
* Checks if a note is visible to the current user *excluding* blocks and mutes.
* @deprecated use isNoteHidden instead
*/
protected isNoteVisibleToMe(note: Packed<'Note'>): boolean {
if (note.visibility === 'public') return true;
@ -120,8 +131,9 @@ export default abstract class Channel {
return note.visibleUserIds.includes(this.user.id);
}
/*
/**
*
* @deprecated use isNoteHidden instead
*/
protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
// Ignore notes that require sign-in
@ -196,12 +208,11 @@ export default abstract class Channel {
// If we didn't clone the notes here, different connections would asynchronously write
// different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar
const clonedNote = deepClone(note);
const notes = crawl(clonedNote);
// Hide notes before everything else, since this modifies fields that the other functions will check.
await this.noteEntityService.hideNotes(notes, this.user.id);
const notes = crawl(clonedNote);
const [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings] = await Promise.all([
const [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings, myFollowings] = await Promise.all([
this.noteEntityService.populateMyReactions(notes, this.user.id, {
myReactions: this.myRecentReactions,
}),
@ -213,13 +224,25 @@ export default abstract class Channel {
}),
this.noteEntityService.populateMyTheadMutings(notes, this.user.id),
this.noteEntityService.populateMyNoteMutings(notes, this.user.id),
this.cacheService.userFollowingsCache.fetch(this.user.id),
]);
note.myReaction = myReactions.get(note.id) ?? null;
note.isRenoted = myRenotes.has(note.id);
note.isFavorited = myFavorites.has(note.id);
note.isMutingThread = myThreadMutings.has(note.id);
note.isMutingNote = myNoteMutings.has(note.id);
for (const n of notes) {
// Sync visibility in case there's something like "makeNotesFollowersOnlyBefore" enabled
this.noteVisibilityService.syncVisibility(n);
n.myReaction = myReactions.get(n.id) ?? null;
n.isRenoted = myRenotes.has(n.id);
n.isFavorited = myFavorites.has(n.id);
n.isMutingThread = myThreadMutings.has(n.id);
n.isMutingNote = myNoteMutings.has(n.id);
n.user.bypassSilence = n.userId === this.user.id || myFollowings.has(n.userId);
}
// Hide notes *after* we sync visibility
await this.noteEntityService.hideNotes(notes, this.user.id, {
userFollowings: myFollowings,
});
return clonedNote;
}

View file

@ -41,7 +41,8 @@ class AntennaChannel extends Channel {
if (data.type === 'note') {
const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true });
if (this.isNoteMutedOrBlocked(note)) return;
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
this.send('note', note);
} else {

View file

@ -8,9 +8,9 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import Channel, { MiChannelService } from '../channel.js';
class BubbleTimelineChannel extends Channel {
@ -47,8 +47,6 @@ class BubbleTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
@ -56,27 +54,9 @@ class BubbleTimelineChannel extends Channel {
if (note.channelId != null) return;
if (!this.utilityService.isBubbledHost(note.user.host)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -8,6 +8,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { JsonObject } from '@/misc/json-value.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class ChannelChannel extends Channel {
@ -45,9 +46,9 @@ class ChannelChannel extends Channel {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
if (this.isNoteMutedOrBlocked(note)) return;
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -48,36 +48,15 @@ class GlobalTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -43,7 +43,8 @@ class HashtagChannel extends Channel {
const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag))));
if (!matched) return;
if (this.isNoteMutedOrBlocked(note)) return;
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -7,7 +7,7 @@ import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -50,28 +50,9 @@ class HomeTimelineChannel extends Channel {
if (!isMe && !this.following.has(note.userId)) return;
}
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -67,28 +67,10 @@ class HybridTimelineChannel extends Channel {
(note.channelId != null && this.followingChannels.has(note.channelId))
)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies && !this.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
if (!this.withReplies && note.replyId != null) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -50,8 +50,6 @@ class LocalTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
@ -59,28 +57,10 @@ class LocalTimelineChannel extends Channel {
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
// 関係ない返信は除外
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
if (!this.withReplies && note.replyId != null) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -35,24 +35,19 @@ class MainChannel extends Channel {
if (isUserFromMutedInstance(data.body, this.userMutedInstances)) return;
if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (data.body.note && data.body.note.isHidden) {
if (this.isNoteMutedOrBlocked(data.body.note)) return;
if (!this.isNoteVisibleToMe(data.body.id)) return;
const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
detail: true,
});
data.body.note = note;
if (data.body.note) {
const { accessible, silence } = await this.checkNoteVisibility(data.body.note, { includeReplies: true });
if (!accessible || silence) return;
data.body.note = await this.rePackNote(data.body.note);
}
break;
}
case 'mention': {
if (this.isNoteMutedOrBlocked(data.body)) return;
if (data.body.isHidden) {
const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true,
});
data.body = note;
}
const { accessible, silence } = await this.checkNoteVisibility(data.body, { includeReplies: true });
if (!accessible || silence) return;
data.body = await this.rePackNote(data.body);
break;
}
}

View file

@ -9,7 +9,7 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote, isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class RoleTimelineChannel extends Channel {
@ -49,26 +49,8 @@ class RoleTimelineChannel extends Channel {
}
if (note.visibility !== 'public') return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note);
if (!accessible || silence) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -9,7 +9,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -82,8 +82,6 @@ class UserListChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
// チャンネル投稿は無視する
if (note.channelId) return;
@ -91,28 +89,9 @@ class UserListChannel extends Channel {
if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following.get(note.userId)?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const { accessible, silence } = await this.checkNoteVisibility(note, { includeReplies: true });
if (!accessible || silence) return;
if (!this.withRenotes && isPackedPureRenote(note)) return;
const clonedNote = await this.rePackNote(note);
this.send('note', clonedNote);

View file

@ -106,8 +106,8 @@ export const moderationLogTypes = [
'deleteUserAnnouncement',
'resetPassword',
'setMandatoryCW',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'setMandatoryCWForNote',
'setMandatoryCWForInstance',
'suspendRemoteInstance',
'unsuspendRemoteInstance',
'rejectRemoteInstanceReports',
@ -294,12 +294,17 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
setRemoteInstanceNSFW: {
id: string;
host: string;
setMandatoryCWForNote: {
newCW: string | null;
oldCW: string | null;
noteId: string;
noteUserId: string;
noteUserUsername: string;
noteUserHost: string | null;
};
unsetRemoteInstanceNSFW: {
id: string;
setMandatoryCWForInstance: {
newCW: string | null;
oldCW: string | null;
host: string;
};
suspendRemoteInstance: {

View file

@ -65,6 +65,7 @@ describe('NoteCreateService', () => {
renoteUserHost: null,
renoteUserInstance: null,
processErrors: [],
mandatoryCW: null,
};
const poll: IPoll = {

View file

@ -48,6 +48,7 @@ const base: MiNote = {
renoteUserHost: null,
renoteUserInstance: null,
processErrors: [],
mandatoryCW: null,
};
describe('misc:is-renote', () => {

View file

@ -9,9 +9,15 @@ import { appendContentWarning } from '@@/js/append-content-warning.js';
export function computeMergedCw(note: Misskey.entities.Note): string | null {
let cw = note.cw;
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
if (note.user.instance?.mandatoryCW) {
cw = appendContentWarning(cw, note.user.instance.mandatoryCW);
}
return cw ?? null;
}

View file

@ -14,28 +14,31 @@ Displays a note with either Misskey or Sharkey style, based on user preference.
:withHardMute="withHardMute"
@reaction="emoji => emit('reaction', emoji)"
@removeReaction="emoji => emit('removeReaction', emoji)"
@expandMute="n => onExpandNote(n)"
/>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
import { defineAsyncComponent, useTemplateRef } from 'vue';
import type { ComponentExposed } from 'vue-component-type-helpers';
import type MkNote from '@/components/MkNote.vue';
import type SkNote from '@/components/SkNote.vue';
import { prefer } from '@/preferences';
import { deepAssign } from '@/utility/merge';
import { useMuteOverrides } from '@/utility/check-word-mute';
const XNote = defineAsyncComponent(() =>
prefer.s.noteDesign === 'misskey'
? import('@/components/MkNote.vue')
: import('@/components/SkNote.vue')
);
? import('@/components/MkNote.vue')
: import('@/components/SkNote.vue'));
const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl');
const muteOverrides = useMuteOverrides();
defineExpose({ rootEl });
defineProps<{
const props = defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
mock?: boolean;
@ -45,5 +48,28 @@ defineProps<{
const emit = defineEmits<{
(ev: 'reaction', emoji: string): void;
(ev: 'removeReaction', emoji: string): void;
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
function onExpandNote(note: Misskey.entities.Note) {
// Expand the user/instance CW for matching subthread (and the inline reply/renote view)
if (note.id === props.note.id) {
deepAssign(muteOverrides, {
user: {
[note.user.id]: {
userMandatoryCW: null,
userSilenced: false,
},
},
instance: {
[note.user.host ?? '']: {
instanceMandatoryCW: null,
instanceSilenced: false,
},
},
});
}
emit('expandMute', note);
}
</script>

View file

@ -11,30 +11,70 @@ Displays a note in the detailed view with either Misskey or Sharkey style, based
:note="note"
:initialTab="initialTab"
:expandAllCws="expandAllCws"
@expandMute="n => onExpandNote(n)"
/>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
import { defineAsyncComponent, useTemplateRef, watch } from 'vue';
import type { ComponentExposed } from 'vue-component-type-helpers';
import type MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import type SkNoteDetailed from '@/components/SkNoteDetailed.vue';
import { prefer } from '@/preferences';
import { useMuteOverrides } from '@/utility/check-word-mute';
import { deepAssign } from '@/utility/merge';
const XNoteDetailed = defineAsyncComponent(() =>
prefer.s.noteDesign === 'misskey'
? import('@/components/MkNoteDetailed.vue')
: import('@/components/SkNoteDetailed.vue'),
);
? import('@/components/MkNoteDetailed.vue')
: import('@/components/SkNoteDetailed.vue'));
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl');
const muteOverrides = useMuteOverrides();
defineExpose({ rootEl });
defineProps<{
const props = defineProps<{
note: Misskey.entities.Note;
initialTab?: string;
expandAllCws?: boolean;
}>();
// Expand mandatory CWs when "expand all CWs" is clicked
watch(() => props.expandAllCws, () => {
deepAssign(muteOverrides, {
all: {
noteMandatoryCW: null,
userMandatoryCW: null,
instanceMandatoryCW: null,
},
});
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
function onExpandNote(note: Misskey.entities.Note) {
// Since this is a Detailed note, note.props must point to the top of a thread.
// Go ahead and expand matching user/instance/thread mutes downstream, since the user is very likely to want them.
if (note.id === props.note.id) {
deepAssign(muteOverrides, {
user: {
[note.user.id]: {
userMandatoryCW: null,
instanceMandatoryCW: null,
},
},
thread: {
[note.threadId]: {
threadMuted: false,
},
},
});
}
emit('expandMute', note);
}
</script>

View file

@ -10,14 +10,16 @@ Displays a note in the simple view with either Misskey or Sharkey style, based o
ref="rootEl"
:note="note"
:expandAllCws="expandAllCws"
:skipMute="skipMute"
:hideFiles="hideFiles"
@editScheduledNote="() => emit('editScheduleNote')"
@expandMute="n => emit('expandMute', n)"
/>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, defineAsyncComponent, useTemplateRef } from 'vue';
import { defineAsyncComponent, useTemplateRef } from 'vue';
import type { ComponentExposed } from 'vue-component-type-helpers';
import type MkNoteSimple from '@/components/MkNoteSimple.vue';
import type SkNoteSimple from '@/components/SkNoteSimple.vue';
@ -25,9 +27,8 @@ import { prefer } from '@/preferences';
const XNoteSimple = defineAsyncComponent(() =>
prefer.s.noteDesign === 'misskey'
? import('@/components/MkNoteSimple.vue')
: import('@/components/SkNoteSimple.vue'),
);
? import('@/components/MkNoteSimple.vue')
: import('@/components/SkNoteSimple.vue'));
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl');
@ -39,10 +40,12 @@ defineProps<{
scheduledNoteId?: string
};
expandAllCws?: boolean;
skipMute?: boolean;
hideFiles?: boolean;
}>();
const emit = defineEmits<{
(ev: 'editScheduleNote'): void;
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
</script>

View file

@ -4,20 +4,22 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
v-if="!hardMuted && muted === false && !threadMuted && !noteMuted"
<SkMutedNote
v-show="!isDeleted"
ref="rootEl"
ref="rootComp"
v-hotkey="keymap"
:note="appearNote"
:withHardMute="withHardMute"
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
:tabindex="isDeleted ? '-1' : '0'"
@expandMute="n => emit('expandMute', n)"
>
<div v-if="appearNote.reply && inReplyToCollapsed" :class="$style.collapsedInReplyTo">
<MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/>
<MkAcct :user="appearNote.reply.user" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/>:
<Mfm :text="getNoteSummary(appearNote.reply)" :plain="true" :nowrap="true" :author="appearNote.reply.user" :nyaize="'respect'" :class="$style.collapsedInReplyToText" @click="inReplyToCollapsed = false"/>
</div>
<MkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<MkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo" @expandMute="n => emit('expandMute', n)"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
<div v-if="isRenote" :class="$style.renote">
<div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
@ -57,10 +59,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<bdi>
<p v-if="mergedCW != null" :class="$style.cw">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="mergedCW != ''"
:text="mergedCW"
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
@ -69,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p>
<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<div>
@ -96,9 +98,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview" :class="[$style.urlPreview, '_gaps_s']" @click.stop>
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds"/>
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" @expandMute="n => emit('expandMute', n)"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
</button>
@ -167,16 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</footer>
</div>
</article>
</div>
<div v-else-if="!hardMuted" :class="$style.muted" @click.stop="muted = false">
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
</div>
<div v-else>
<!--
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
so MkNote create empty div instead of no elements
-->
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
@ -186,7 +179,6 @@ import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
@ -205,7 +197,6 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkButton from '@/components/MkButton.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -217,7 +208,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
@ -254,6 +245,7 @@ provide(DI.mock, props.mock);
const emit = defineEmits<{
(ev: 'reaction', emoji: string): void;
(ev: 'removeReaction', emoji: string): void;
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const router = useRouter();
@ -272,7 +264,8 @@ function noteclick(id: string) {
const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = useTemplateRef('rootEl');
const rootComp = useTemplateRef('rootComp');
const rootEl = computed(() => rootComp.value?.rootEl ?? null);
const menuButton = useTemplateRef('menuButton');
const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
@ -291,7 +284,6 @@ const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const { muted, hardMuted, threadMuted, noteMuted } = checkMutes(appearNote, computed(() => props.withHardMute));
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
@ -314,8 +306,6 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(appearNote);
let renoting = false;
@ -1291,16 +1281,7 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
// Mute CSS moved to SkMutedNote.vue
.reactionOmitted {
display: inline-block;

View file

@ -4,21 +4,22 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
v-if="!muted && !threadMuted && !noteMuted"
<SkMutedNote
v-show="!isDeleted"
ref="rootEl"
ref="rootComp"
v-hotkey="keymap"
:note="appearNote"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
@expandMute="n => emit('expandMute', n)"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadConversation">{{ i18n.ts.loadConversation }}</MkButton>
</div>
<MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws"/>
<MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws" @expandMute="n => emit('expandMute', n)"/>
</div>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws"/>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" @expandMute="n => emit('expandMute', n)"/>
<div v-if="isRenote" :class="$style.renote">
<MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/>
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
@ -75,10 +76,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</header>
<div :class="$style.noteContent">
<p v-if="mergedCW != null" :class="$style.cw">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="mergedCW != ''"
:text="mergedCW"
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
@ -87,7 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="mergedCW == null || showContent">
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<div>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
@ -112,9 +113,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview" class="_gaps_s" style="margin-top: 6px;" @click.stop>
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds"/>
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws" @expandMute="n => emit('expandMute', n)"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
@ -188,7 +189,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :pagination="renotesPagination" :disableAutoLoad="true">
@ -205,7 +206,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="!quotesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws"/>
<MkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
@ -225,10 +226,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPagination>
</div>
</div>
</div>
<div v-else class="_panel" :class="$style.muted" @click.stop="muted = false">
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
@ -237,7 +235,6 @@ import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { Keymap } from '@/utility/hotkey.js';
@ -253,7 +250,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
@ -264,7 +260,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
@ -296,13 +292,18 @@ const props = withDefaults(defineProps<{
initialTab: 'replies',
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note));
const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = useTemplateRef('rootEl');
const rootComp = useTemplateRef('rootComp');
const rootEl = computed(() => rootComp.value?.rootEl ?? null);
const menuButton = useTemplateRef('menuButton');
const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
@ -329,12 +330,8 @@ const quotes = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(appearNote);
const { muted, threadMuted, noteMuted } = checkMutes(appearNote);
setupNoteViewInterruptors(note, isDeleted);
watch(() => props.expandAllCws, (expandAllCws) => {
@ -1149,14 +1146,5 @@ function animatedMFM() {
}
}
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
// Mute CSS moved to SkMutedNote.vue
</style>

View file

@ -4,16 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="!isDeleted" :class="$style.root">
<SkMutedNote v-if="!isDeleted" :note="note" :skipMute="skipMute" :class="$style.root" @expandMute="n => emit('expandMute', n)">
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<p v-if="props.note.cw != null" :class="$style.cw">
<Mfm v-if="props.note.cw != ''" style="margin-right: 8px;" :text="props.note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p>
<div v-show="mergedCW == null || showContent">
<div v-show="props.note.cw == null || showContent">
<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
<div v-if="note.isSchedule" style="margin-top: 10px;">
<MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.edit }}</MkButton>
@ -22,18 +22,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import * as os from '@/os.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkButton from '@/components/MkButton.vue';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { setupNoteViewInterruptors } from '@/plugin.js';
@ -45,6 +45,7 @@ const props = defineProps<{
scheduledNoteId?: string
};
expandAllCws?: boolean;
skipMute?: boolean;
hideFiles?: boolean;
}>();
@ -53,14 +54,13 @@ const isDeleted = ref(false);
const note = ref(deepClone(props.note));
const mergedCW = computed(() => computeMergedCw(note.value));
if (!note.value.isSchedule) {
setupNoteViewInterruptors(note, null);
}
const emit = defineEmits<{
(ev: 'editScheduleNote'): void;
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
async function deleteScheduleNote() {

View file

@ -4,18 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-show="!isDeleted" v-if="!muted && !noteMuted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]">
<SkMutedNote v-show="!isDeleted" ref="rootComp" :note="appearNote" :mutedClass="$style.muted" :expandedClass="[$style.root, { [$style.children]: depth > 1 }]" @expandMute="n => emit('expandMute', n)">
<div :class="$style.main">
<div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.body">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div :class="$style.content">
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="mergedCW == null || showContent">
<div v-show="appearNote.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
</div>
</div>
@ -72,21 +72,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<template v-if="depth < prefer.s.numberOfReplies">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/>
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" @expandMute="n => emit('expandMute', n)"/>
</template>
<div v-else :class="$style.more">
<MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA>
</div>
</div>
<div v-else :class="$style.muted" @click.stop="muted = false">
<SkMutedNote :muted="muted" :threadMuted="false" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import * as config from '@@/js/config.js';
import type { Ref } from 'vue';
import type { Visibility } from '@/utility/boost-quote.js';
@ -102,7 +98,6 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { pleaseLogin } from '@/utility/please-login.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
@ -131,13 +126,18 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined,
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const note = ref(deepClone(props.note));
const appearNote = computed(() => getAppearNote(note.value));
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const el = shallowRef<HTMLElement>();
const rootComp = useTemplateRef('rootComp');
const el = computed(() => rootComp.value?.rootEl ?? null);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const isDeleted = ref(false);
@ -153,8 +153,6 @@ const renoteTooltip = computeRenoteTooltip(appearNote);
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@ -177,8 +175,6 @@ async function removeReply(id: Misskey.entities.Note['id']) {
}
}
const { muted, noteMuted } = checkMutes(appearNote);
useNoteCapture({
rootEl: el,
note: appearNote,
@ -504,15 +500,8 @@ if (props.detail) {
}
.muted {
text-align: center;
padding: 8px !important;
border: 1px solid var(--MI_THEME-divider);
margin: 8px 8px 0 8px;
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
</style>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
<template v-for="(note, i) in notes" :key="note.id">
<DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/>
<DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id" @expandMute="n => emit('expandMute', n)"/>
<MkAd v-if="note._shouldInsertAd_" :preferForms="['horizontal', 'horizontal-big']" :class="$style.ad"/>
</template>
</div>
@ -37,6 +37,10 @@ const pagingComponent = useTemplateRef('pagingComponent');
defineExpose({
pagingComponent,
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
</script>
<style lang="scss" module>

View file

@ -52,8 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
</header>
<MkNoteSimple v-if="reply" :class="$style.targetNote" :hideFiles="true" :note="reply"/>
<MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :hideFiles="true" :note="renoteTargetNote"/>
<MkNoteSimple v-if="reply" :class="$style.targetNote" :hideFiles="true" :note="reply" :skipMute="true"/>
<MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :hideFiles="true" :note="renoteTargetNote" :skipMute="true"/>
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null; renoteTargetNote = null;"><i class="ti ti-x"></i></button></div>
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>

View file

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items }">
<div class="_gaps">
<MkNoteSimple v-for="item in items" :key="item.id" :scheduled="true" :note="item.note" @editScheduleNote="listUpdate"/>
<MkNoteSimple v-for="item in items" :key="item.id" :scheduled="true" :note="item.note" :skipMute="true" @editScheduleNote="listUpdate"/>
</div>
</template>
</MkPagination>

View file

@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton>
</div>
</template>
<div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><DynamicNoteSimple :note="theNote" :class="$style.body"/></div>
<div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><DynamicNoteSimple :note="theNote" :class="$style.body" @expandMute="n => emit('expandMute', n)"/></div>
<div v-else-if="!hidePreview">
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url" @click.prevent="self ? true : warningExternalWebsite(url)" @click.stop>
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreview ? '' : { backgroundImage: `url('${thumbnail}')` }">
@ -151,6 +151,10 @@ const props = withDefaults(defineProps<{
attributionHint: undefined,
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const MOBILE_THRESHOLD = 500;
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);

View file

@ -6,7 +6,7 @@ Selectable entry on the "Following" feed, displaying a user with their most rece
-->
<template>
<div v-if="!hardMuted" :class="$style.root" @click="$emit('select', note.user)">
<SkMutedNote :note="note" :mutedClass="$style.muted" :expandedClass="$style.root" @click="$emit('select', note.user)">
<div :class="$style.avatar">
<MkAvatar :class="$style.icon" :user="note.user" indictor/>
</div>
@ -20,39 +20,26 @@ Selectable entry on the "Following" feed, displaying a user with their most rece
</MkA>
</header>
<div>
<div v-if="muted || threadMuted || noteMuted" :class="[$style.text, $style.muted]">
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="note"></SkMutedNote>
</div>
<Mfm v-else :class="$style.text" :text="getNoteSummary(note)" :isBlock="true" :plain="true" :nowrap="false" :isNote="true" nyaize="respect" :author="note.user"/>
<Mfm :class="$style.text" :text="getNoteSummary(note, false)" :isBlock="true" :plain="true" :nowrap="false" :isNote="true" nyaize="respect" :author="note.user"/>
</div>
</div>
</div>
<div v-else>
<!--
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
so MkNote create empty div instead of no elements
-->
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { computed } from 'vue';
import { getNoteSummary } from '@/utility/get-note-summary.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import { checkMutes } from '@/utility/check-word-mute';
import SkMutedNote from '@/components/SkMutedNote.vue';
const props = defineProps<{
defineProps<{
note: Misskey.entities.Note,
}>();
defineEmits<{
(event: 'select', user: Misskey.entities.UserLite): void
}>();
const { muted, hardMuted, threadMuted, noteMuted } = checkMutes(computed(() => props.note));
</script>
<style lang="scss" module>
@ -67,6 +54,10 @@ const { muted, hardMuted, threadMuted, noteMuted } = checkMutes(computed(() => p
cursor: pointer;
}
.root:hover {
background: var(--MI_THEME-buttonBg);
}
.avatar {
align-self: center;
flex-shrink: 0;
@ -116,6 +107,8 @@ const { muted, hardMuted, threadMuted, noteMuted } = checkMutes(computed(() => p
.muted {
font-style: italic;
align-items: center;
justify-content: center;
}
@container (max-width: 600px) {

View file

@ -6,62 +6,168 @@ Displays a placeholder for a muted note.
-->
<template>
<I18n v-if="noteMuted" :src="i18n.ts.userSaysSomethingInMutedNote" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<I18n v-else-if="threadMuted" :src="i18n.ts.userSaysSomethingInMutedThread" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<div ref="rootEl" :class="rootClass">
<!-- The actual note (or whatever we're wrapping) will render here. -->
<slot v-if="isExpanded"></slot>
<br v-if="threadMuted && muted">
<!-- If hard muted, we want to hide *everything*, including the placeholders and controls to expand. -->
<div v-else-if="!mute.hardMuted" :class="[$style.muted, $style.muteContainer, mutedClass]" @click.stop="expand">
<!-- Mandatory CWs -->
<I18n v-if="mute.noteMandatoryCW" :src="i18n.ts.noteIsFlaggedAs" tag="small">
<template #cw>
{{ mute.noteMandatoryCW }}
</template>
</I18n>
<I18n v-if="mute.userMandatoryCW" :src="i18n.ts.userIsFlaggedAs" tag="small">
<template #name>
{{ userName }}
</template>
<template #cw>
{{ mute.userMandatoryCW }}
</template>
</I18n>
<I18n v-if="mute.instanceMandatoryCW" :src="i18n.ts.instanceIsFlaggedAs" tag="small">
<template #name>
{{ instanceName }}
</template>
<template #cw>
{{ mute.instanceMandatoryCW }}
</template>
</I18n>
<template v-if="muted">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkUserName :user="note.user"/>
<!-- Muted notes/threads -->
<I18n v-if="mute.noteMuted" :src="i18n.ts.userSaysSomethingInMutedNote" tag="small">
<template #name>
{{ userName }}
</template>
</I18n>
<I18n v-else-if="mute.threadMuted" :src="i18n.ts.userSaysSomethingInMutedThread" tag="small">
<template #name>
{{ userName }}
</template>
</I18n>
<!-- Silenced users/instances -->
<I18n v-if="mute.userSilenced" :src="i18n.ts.silencedUserSaysSomething" tag="small">
<template #name>
{{ userName }}
</template>
<template #host>
{{ host }}
</template>
</I18n>
<I18n v-if="mute.instanceSilenced" :src="i18n.ts.silencedInstanceSaysSomething" tag="small">
<template #name>
{{ instanceName }}
</template>
<template #host>
{{ host }}
</template>
</I18n>
<!-- Word mutes -->
<template v-if="mutedWords">
<I18n v-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
{{ userName }}
</template>
<template #word>
{{ mutedWords }}
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
{{ userName }}
</template>
</I18n>
</template>
</I18n>
<I18n v-else-if="!prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
<template #word>
{{ mutedWords }}
</template>
</I18n>
</template>
<!-- Sensitive mute -->
<I18n v-if="mute.sensitiveMuted" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
{{ userName }}
</template>
</I18n>
</div>
</div>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { host } from '@@/js/config.js';
import type { Ref } from 'vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { checkMute } from '@/utility/check-word-mute.js';
const props = withDefaults(defineProps<{
muted: false | 'sensitiveMute' | string[];
threadMuted?: boolean;
noteMuted?: boolean;
note: Misskey.entities.Note;
withHardMute?: boolean;
mutedClass?: string | string[] | Record<string, boolean> | (string | string[] | Record<string, boolean>)[];
expandedClass?: string | string[] | Record<string, boolean> | (string | string[] | Record<string, boolean>)[];
skipMute?: boolean;
}>(), {
threadMuted: false,
noteMuted: false,
withHardMute: true,
mutedClass: undefined,
expandedClass: undefined,
skipMute: false,
});
const mutedWords = computed(() => Array.isArray(props.muted)
? props.muted.join(', ')
: props.muted);
const emit = defineEmits<{
(type: 'expandMute', note: Misskey.entities.Note): void;
}>();
const expandNote = ref(false);
function expand() {
expandNote.value = true;
emit('expandMute', props.note);
}
const mute = checkMute(
computed(() => props.note),
computed(() => props.withHardMute),
computed(() => prefer.s.uncollapseCW),
);
const mutedWords = computed(() => mute.value.softMutedWords?.join(', '));
const isExpanded = computed(() => props.skipMute || expandNote.value || !mute.value.hasMute);
const rootClass = computed(() => isExpanded.value ? props.expandedClass : undefined);
const userName = computed(() => props.note.user.host
? `@${props.note.user.username}@${props.note.user.host}`
: `@${props.note.user.username}`);
const instanceName = computed(() => props.note.user.host ?? host);
const rootEl = useTemplateRef('rootEl');
defineExpose({
rootEl: rootEl as Ref<HTMLElement | null>,
});
</script>
<style module lang="scss">
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
// Without this, the mute placeholder collapses weirdly when the note is rendered in a flax container.
flex: 1;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
.muteContainer > :not(:first-child) {
margin-left: 0.75rem;
&:before {
content: "•";
margin-right: 0.75rem;
font-size: 1rem;
}
}
</style>

View file

@ -6,15 +6,17 @@ Displays a note in the Sharkey style. Used to show the "main" note in a given co
-->
<template>
<div
v-if="!hardMuted && muted === false && !threadMuted && !noteMuted"
<SkMutedNote
v-show="!isDeleted"
ref="rootEl"
ref="rootComp"
v-hotkey="keymap"
:note="appearNote"
:withHardMute="withHardMute"
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
:tabindex="isDeleted ? '-1' : '0'"
@expandMute="n => emit('expandMute', n)"
>
<SkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<SkNoteSub v-if="appearNote.reply" v-show="!renoteCollapsed && !inReplyToCollapsed" :note="appearNote.reply" :class="$style.replyTo" @expandMute="n => emit('expandMute', n)"/>
<div v-if="appearNote.reply && inReplyToCollapsed && !renoteCollapsed" :class="$style.collapsedInReplyTo">
<div :class="$style.collapsedInReplyToLine"></div>
<MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/>
@ -62,10 +64,10 @@ Displays a note in the Sharkey style. Used to show the "main" note in a given co
</div>
<div :class="[{ [$style.clickToOpen]: prefer.s.clickToOpen }]" @click.stop="prefer.s.clickToOpen ? noteclick(appearNote.id) : undefined">
<div style="container-type: inline-size;">
<p v-if="mergedCW != null" :class="$style.cw">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="mergedCW != ''"
:text="mergedCW"
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
@ -75,7 +77,7 @@ Displays a note in the Sharkey style. Used to show the "main" note in a given co
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p>
<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<Mfm
@ -99,7 +101,7 @@ Displays a note in the Sharkey style. Used to show the "main" note in a given co
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview" :class="[$style.urlPreview, '_gaps_s']" @click.stop>
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds"/>
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
@ -169,16 +171,7 @@ Displays a note in the Sharkey style. Used to show the "main" note in a given co
</footer>
</div>
</article>
</div>
<div v-else-if="!hardMuted" :class="$style.muted" @click.stop="muted = false">
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
</div>
<div v-else>
<!--
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
so MkNote create empty div instead of no elements
-->
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
@ -188,7 +181,6 @@ import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
@ -206,7 +198,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkButton from '@/components/MkButton.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -240,6 +231,7 @@ import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
import MkNoteSub from '@/components/MkNoteSub.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -255,6 +247,7 @@ provide(DI.mock, props.mock);
const emit = defineEmits<{
(ev: 'reaction', emoji: string): void;
(ev: 'removeReaction', emoji: string): void;
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const router = useRouter();
@ -273,7 +266,8 @@ function noteclick(id: string) {
const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = useTemplateRef('rootEl');
const rootComp = useTemplateRef('rootComp');
const rootEl = computed(() => rootComp.value?.rootEl ?? null);
const menuButton = useTemplateRef('menuButton');
const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
@ -292,7 +286,6 @@ const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const { muted, hardMuted, threadMuted, noteMuted } = checkMutes(appearNote, computed(() => props.withHardMute));
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
@ -315,8 +308,6 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(appearNote);
let renoting = false;
@ -1341,16 +1332,7 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
// Mute CSS moved to SkMutedNote.vue
.reactionOmitted {
display: inline-block;

View file

@ -6,13 +6,14 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
-->
<template>
<div
v-if="!muted && !threadMuted && !noteMuted"
<SkMutedNote
v-show="!isDeleted"
ref="rootEl"
ref="rootComp"
v-hotkey="keymap"
:note="appearNote"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
@expandMute="n => emit('expandMute', n)"
>
<div v-if="appearNote.reply && appearNote.reply.replyId && !conversationLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadConversation">{{ i18n.ts.loadConversation }}</MkButton>
@ -43,9 +44,9 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
</div>
</div>
<template v-if="appearNote.reply && appearNote.reply.replyId">
<SkNoteSub v-for="note in conversation" :key="note.id" :note="note" :expandAllCws="props.expandAllCws" detailed/>
<SkNoteSub v-for="note in conversation" :key="note.id" :note="note" :expandAllCws="props.expandAllCws" detailed @expandMute="n => emit('expandMute', n)"/>
</template>
<SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed/>
<SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed @expandMute="n => emit('expandMute', n)"/>
<article :id="appearNote.id" ref="noteEl" :class="$style.note" tabindex="-1" @contextmenu.stop="onContextmenu">
<header :class="$style.noteHeader">
<MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
@ -83,10 +84,10 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
</div>
</header>
<div :class="$style.noteContent">
<p v-if="mergedCW != null" :class="$style.cw">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="mergedCW != ''"
:text="mergedCW"
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
@ -95,7 +96,7 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="mergedCW == null || showContent">
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<Mfm
v-if="appearNote.text"
@ -118,7 +119,7 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview" class="_gaps_s" style="margin-top: 6px;" @click.stop>
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds"/>
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -194,7 +195,7 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="true"/>
<SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="true" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :pagination="renotesPagination" :disableAutoLoad="true">
@ -211,7 +212,7 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
<div v-if="!quotesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :reply="true"/>
<SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :reply="true" @expandMute="n => emit('expandMute', n)"/>
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
@ -231,10 +232,7 @@ Detailed view of a note in the Sharkey style. Used when opening a note onto its
</MkPagination>
</div>
</div>
</div>
<div v-else class="_panel" :class="$style.muted" @click.stop="muted = false">
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
@ -243,7 +241,6 @@ import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { Keymap } from '@/utility/hotkey.js';
@ -259,7 +256,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import SkInstanceTicker from '@/components/SkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
@ -293,6 +289,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
import MkNoteSub from '@/components/MkNoteSub.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -302,13 +299,18 @@ const props = withDefaults(defineProps<{
initialTab: 'replies',
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note));
const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = useTemplateRef('rootEl');
const rootComp = useTemplateRef('rootComp');
const rootEl = computed(() => rootComp.value?.rootEl ?? null);
const noteEl = useTemplateRef('noteEl');
const menuButton = useTemplateRef('menuButton');
const renoteButton = useTemplateRef('renoteButton');
@ -336,12 +338,8 @@ const quotes = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(appearNote);
const { muted, threadMuted, noteMuted } = checkMutes(appearNote);
setupNoteViewInterruptors(note, isDeleted);
watch(() => props.expandAllCws, (expandAllCws) => {
@ -1226,16 +1224,7 @@ onUnmounted(() => {
border-radius: var(--MI-radius-sm) !important;
}
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
// Mute CSS moved to SkMutedNote.vue
.badgeRoles {
margin: 0 .5em 0 0;

View file

@ -6,46 +6,53 @@ Simple view of a note in the Sharkey style. Used in quote renotes, link previews
-->
<template>
<div :class="$style.root">
<SkMutedNote :note="note" :skipMute="skipMute" :class="$style.root" @expandMute="n => emit('expandMute', n)">
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/>
<div>
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<p v-if="props.note.cw != null" :class="$style.cw">
<Mfm v-if="props.note.cw != ''" style="margin-right: 8px;" :text="props.note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p>
<div v-show="mergedCW == null || showContent">
<div v-show="props.note.cw == null || showContent">
<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
</div>
</div>
</div>
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
import { watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { prefer } from '@/preferences.js';
import { setupNoteViewInterruptors } from '@/plugin.js';
import { deepClone } from '@/utility/clone.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
const props = defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note & {
isSchedule?: boolean,
scheduledNoteId?: string
};
expandAllCws?: boolean;
skipMute?: boolean;
hideFiles?: boolean;
}>();
const emit = defineEmits<{
(ev: 'editScheduleNote'): void;
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
let showContent = ref(prefer.s.uncollapseCW);
const note = ref(deepClone(props.note));
const mergedCW = computed(() => computeMergedCw(note.value));
setupNoteViewInterruptors(note, null);
watch(() => props.expandAllCws, (expandAllCws) => {

View file

@ -8,7 +8,7 @@ For example, when viewing a reply on the timeline, SkNoteSub will be used to dis
-->
<template>
<div v-show="!isDeleted" v-if="!muted && !noteMuted" ref="el" :class="[$style.root, { [$style.children]: depth > 1, [$style.isReply]: props.isReply, [$style.detailed]: props.detailed }]">
<SkMutedNote v-show="!isDeleted" ref="rootComp" :note="appearNote" :mutedClass="$style.muted" :expandedClass="[$style.root, { [$style.children]: depth > 1, [$style.isReply]: props.isReply, [$style.detailed]: props.detailed }]" @expandMute="n => emit('expandMute', n)">
<div v-if="!hideLine" :class="$style.line"></div>
<div :class="$style.main">
<div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
@ -23,11 +23,11 @@ For example, when viewing a reply on the timeline, SkNoteSub will be used to dis
<div :class="$style.body">
<SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/>
<div :class="$style.content">
<p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="mergedCW == null || showContent">
<div v-show="appearNote.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
</div>
</div>
@ -84,21 +84,17 @@ For example, when viewing a reply on the timeline, SkNoteSub will be used to dis
</div>
</div>
<template v-if="depth < prefer.s.numberOfReplies">
<SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="props.isReply"/>
<SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="props.isReply" @expandMute="n => emit('expandMute', n)"/>
</template>
<div v-else :class="$style.more">
<MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA>
</div>
</div>
<div v-else :class="$style.muted" @click.stop="muted = false">
<SkMutedNote :muted="muted" :threadMuted="false" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
</div>
</SkMutedNote>
</template>
<script lang="ts" setup>
import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import * as config from '@@/js/config.js';
import type { Ref } from 'vue';
import type { Visibility } from '@/utility/boost-quote.js';
@ -114,7 +110,6 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { pleaseLogin } from '@/utility/please-login.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
@ -148,6 +143,10 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined,
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const note = ref(deepClone(props.note));
const appearNote = computed(() => getAppearNote(note.value));
@ -171,8 +170,6 @@ const renoteTooltip = computeRenoteTooltip(appearNote);
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@ -195,8 +192,6 @@ async function removeReply(id: Misskey.entities.Note['id']) {
}
}
const { muted, noteMuted } = checkMutes(appearNote);
useNoteCapture({
rootEl: el,
note: appearNote,
@ -602,16 +597,9 @@ if (props.detail) {
}
.muted {
text-align: center;
padding: 8px !important;
border: 1px solid var(--MI_THEME-divider);
margin: 8px 8px 0 8px;
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
// avatar container with line

View file

@ -52,7 +52,7 @@ Displays an old version of an edited note.
<div class="_gaps_s" style="margin-top: 6px;" @click.stop>
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :skipMute="true"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
</div>

View file

@ -27,26 +27,26 @@ import { i18n } from '@/i18n';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { parseMutes } from '@/utility/parse-mutes';
import { checkWordMute } from '@/utility/check-word-mute';
import { parseMutes } from '@/utility/parse-mutes.js';
import { getMutedWords } from '@/utility/check-word-mute.js';
const props = defineProps<{
mutedWords?: string | null,
mutedWords: string,
}>();
const testWords = ref<string | null>(null);
const testMatches = ref<string | null>(null);
function testWordMutes() {
if (!testWords.value || !props.mutedWords) {
if (!testWords.value) {
testMatches.value = null;
return;
}
try {
const mutes = parseMutes(props.mutedWords);
const matches = checkWordMute(testWords.value, null, mutes);
testMatches.value = matches ? matches.join(', ') : '';
const matches = getMutedWords(mutes, testWords.value);
testMatches.value = matches.join(', ');
} catch {
// Error is displayed by above function
testMatches.value = null;

View file

@ -25,6 +25,7 @@ Attempts to avoid displaying the same preview twice, even if multiple URLs point
:showAsQuote="showAsQuote"
:showActions="showActions"
:skipNoteIds="skipNoteIds"
@expandMute="n => onExpandNote(n)"
></MkUrlPreview>
</template>
</template>
@ -42,6 +43,8 @@ import { $i } from '@/i';
import { misskeyApi } from '@/utility/misskey-api';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { getNoteUrls } from '@/utility/getNoteUrls';
import { deepAssign } from '@/utility/merge';
import { useMuteOverrides } from '@/utility/check-word-mute';
type Summary = SummalyResult & {
note?: Misskey.entities.Note | null;
@ -74,6 +77,32 @@ const props = withDefaults(defineProps<{
skipNoteIds: () => [],
});
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const muteOverrides = useMuteOverrides();
function onExpandNote(note: Misskey.entities.Note) {
// Expand related mutes within this preview group
deepAssign(muteOverrides, {
user: {
[note.user.id]: {
userMandatoryCW: null,
userSilenced: false,
},
},
instance: {
[note.user.host ?? '']: {
instanceMandatoryCW: null,
instanceSilenced: false,
},
},
});
emit('expandMute', note);
}
const urlPreviews = ref<Summary[]>([]);
const urls = computed<string[]>(() => {

View file

@ -9,7 +9,7 @@ Displays a user's recent notes for the "Following" feed.
<MkPullToRefresh :refresher="() => reload()">
<div v-if="user" :class="$style.userInfo">
<MkUserInfo :class="$style.userInfo" class="user" :user="user"/>
<MkNotes :noGap="true" :pagination="pagination"/>
<MkNotes :noGap="true" :pagination="pagination" @expandMute="n => onExpandMute(n)"/>
</div>
<div v-else-if="loadError" :class="$style.panel">{{ loadError }}</div>
<MkLoading v-else-if="userId"/>
@ -26,6 +26,8 @@ import MkNotes from '@/components/MkNotes.vue';
import MkUserInfo from '@/components/MkUserInfo.vue';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useMuteOverrides } from '@/utility/check-word-mute';
import { deepAssign } from '@/utility/merge';
const props = defineProps<{
userId: string;
@ -54,6 +56,22 @@ const pagination: Paging<'users/notes'> = {
})),
};
const muteOverrides = useMuteOverrides();
function onExpandMute(note: Misskey.entities.Note) {
if (note.user.id === props.userId) {
// This kills the mandatoryCW for this user below this point
deepAssign(muteOverrides, {
user: {
[props.userId]: {
userMandatoryCW: null,
instanceMandatoryCW: null,
},
},
});
}
}
defineExpose({
reload,
user,

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="getComponent(block.type)" :key="block.id" :page="page" :block="block" :h="h"/>
<component :is="getComponent(block.type)" :key="block.id" :page="page" :block="block" :h="h" @expandMute="n => onExpandNote(n)"/>
</template>
<script lang="ts" setup>
@ -15,6 +15,8 @@ import XSection from './page.section.vue';
import XImage from './page.image.vue';
import XNote from './page.note.vue';
import XDynamic from './page.dynamic.vue';
import { deepAssign } from '@/utility/merge';
import { useMuteOverrides } from '@/utility/check-word-mute';
function getComponent(type: string) {
switch (type) {
@ -45,4 +47,30 @@ defineProps<{
h: number,
page: Misskey.entities.Page,
}>();
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const muteOverrides = useMuteOverrides();
function onExpandNote(note: Misskey.entities.Note) {
// Expand related mutes within this page group
deepAssign(muteOverrides, {
user: {
[note.user.id]: {
userMandatoryCW: null,
userSilenced: false,
},
},
instance: {
[note.user.host ?? '']: {
instanceMandatoryCW: null,
instanceSilenced: false,
},
},
});
emit('expandMute', note);
}
</script>

View file

@ -23,6 +23,10 @@ const props = defineProps<{
block: Misskey.entities.PageBlock,
page: Misskey.entities.Page,
}>();
defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
</script>
<style lang="scss" module>

View file

@ -19,6 +19,10 @@ const props = defineProps<{
page: Misskey.entities.Page,
}>();
defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const image = ref<Misskey.entities.DriveFile | null>(null);
onMounted(() => {

View file

@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note"/>
<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note"/>
<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note" @expandMute="n => emit('expandMute', n)"/>
<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note" @expandMute="n => emit('expandMute', n)"/>
</div>
</template>
@ -24,6 +24,10 @@ const props = defineProps<{
index: number;
}>();
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
const note = ref<Misskey.entities.Note | null>(null);
// eslint-disable-next-line id-denylist

View file

@ -33,6 +33,10 @@ defineProps<{
h: number,
page: Misskey.entities.Page,
}>();
defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
</script>
<style lang="scss" module>

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" :class="$style.textRoot">
<Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/>
<div v-if="isEnabledUrlPreview" class="_gaps_s" @click.stop>
<SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes"/>
<SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes" @expandMute="n => emit('expandMute', n)"/>
</div>
</div>
</template>
@ -21,6 +21,10 @@ defineProps<{
block: Misskey.entities.PageBlock,
page: Misskey.entities.Page,
}>();
const emit = defineEmits<{
(ev: 'expandMute', note: Misskey.entities.Note): void;
}>();
</script>
<style lang="scss" module>

Some files were not shown because too many files have changed in this diff Show more