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:
commit
741e612508
125 changed files with 3195 additions and 1338 deletions
|
|
@ -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
54
locales/index.d.ts
vendored
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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%'`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
465
packages/backend/src/core/NoteVisibilityService.ts
Normal file
465
packages/backend/src/core/NoteVisibilityService.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
}
|
||||
|
||||
// 本文
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
112
packages/backend/src/server/api/endpoints/admin/cw-note.ts
Normal file
112
packages/backend/src/server/api/endpoints/admin/cw-note.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ describe('NoteCreateService', () => {
|
|||
renoteUserHost: null,
|
||||
renoteUserInstance: null,
|
||||
processErrors: [],
|
||||
mandatoryCW: null,
|
||||
};
|
||||
|
||||
const poll: IPoll = {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const base: MiNote = {
|
|||
renoteUserHost: null,
|
||||
renoteUserInstance: null,
|
||||
processErrors: [],
|
||||
mandatoryCW: null,
|
||||
};
|
||||
|
||||
describe('misc:is-renote', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]>(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ defineProps<{
|
|||
h: number,
|
||||
page: Misskey.entities.Page,
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(ev: 'expandMute', note: Misskey.entities.Note): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue