merge: Hide muted threads and notes from timelines (!1142)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1142 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
957116d04a
51 changed files with 891 additions and 351 deletions
20
locales/index.d.ts
vendored
20
locales/index.d.ts
vendored
|
|
@ -11988,6 +11988,22 @@ export interface Locale extends ILocale {
|
|||
* Boosts muted
|
||||
*/
|
||||
"renoteMuted": string;
|
||||
/**
|
||||
* Mute note
|
||||
*/
|
||||
"muteNote": string;
|
||||
/**
|
||||
* Unmute note
|
||||
*/
|
||||
"unmuteNote": string;
|
||||
/**
|
||||
* {name} said something in a muted post
|
||||
*/
|
||||
"userSaysSomethingInMutedNote": ParameterizedString<"name">;
|
||||
/**
|
||||
* {name} said something in a muted thread
|
||||
*/
|
||||
"userSaysSomethingInMutedThread": ParameterizedString<"name">;
|
||||
/**
|
||||
* Mark all media from user as NSFW
|
||||
*/
|
||||
|
|
@ -12265,6 +12281,10 @@ export interface Locale extends ILocale {
|
|||
* Collapse files
|
||||
*/
|
||||
"collapseFiles": string;
|
||||
/**
|
||||
* Clone
|
||||
*/
|
||||
"clone": string;
|
||||
/**
|
||||
* Uncollapse CWs on notes
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddNoteThreadMutingIsPostMute1749523586531 {
|
||||
name = 'AddNoteThreadMutingIsPostMute1749523586531'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ae7aab18a2641d3e5f25e0c4ea"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD "isPostMute" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_01f7ab05099400012e9a7fd42b" ON "note_thread_muting" ("userId", "threadId", "isPostMute") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_01f7ab05099400012e9a7fd42b"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`);
|
||||
await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP COLUMN "isPostMute"`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ae7aab18a2641d3e5f25e0c4ea" ON "note_thread_muting" ("userId", "threadId") `);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -46,6 +46,8 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public userBlockingCache: QuantumKVCache<Set<string>>;
|
||||
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public threadMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public noteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public hibernatedUserCache: QuantumKVCache<boolean>;
|
||||
|
|
@ -77,6 +79,9 @@ export class CacheService implements OnApplicationShutdown {
|
|||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private readonly noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {
|
||||
|
|
@ -145,6 +150,36 @@ export class CacheService implements OnApplicationShutdown {
|
|||
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||
});
|
||||
|
||||
this.threadMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'threadMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: false }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.groupBy('"muting"."userId"')
|
||||
.where({ userId: In(muterIds), isPostMute: false })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.noteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'noteMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: true }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.groupBy('"muting"."userId"')
|
||||
.where({ userId: In(muterIds), isPostMute: true })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),
|
||||
|
|
@ -272,6 +307,8 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userFollowingsCache.delete(body.id),
|
||||
this.userFollowersCache.delete(body.id),
|
||||
this.hibernatedUserCache.delete(body.id),
|
||||
this.threadMutingsCache.delete(body.id),
|
||||
this.noteMutingsCache.delete(body.id),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -542,7 +579,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userBlockingCache.dispose();
|
||||
this.userBlockedCache.dispose();
|
||||
this.renoteMutingsCache.dispose();
|
||||
this.threadMutingsCache.dispose();
|
||||
this.noteMutingsCache.dispose();
|
||||
this.userFollowingsCache.dispose();
|
||||
this.userFollowersCache.dispose();
|
||||
this.hibernatedUserCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -676,18 +676,15 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.reply.threadId ?? data.reply.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.reply.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.reply.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
|
|
@ -705,14 +702,17 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// Notify
|
||||
if (data.renote.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.renote.userId,
|
||||
threadId: data.renote.threadId ?? data.renote.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.renote.threadId ?? data.renote.id;
|
||||
|
||||
const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId));
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.renote.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.renote.userId),
|
||||
]);
|
||||
|
||||
const muted = data.renote.userId && isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
if (!isThreadMuted && !muted) {
|
||||
nm.push(data.renote.userId, type);
|
||||
|
|
@ -842,18 +842,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
|
||||
const [
|
||||
threadMutings,
|
||||
userMutings,
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
|
||||
this.cacheService.userMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
|
||||
]);
|
||||
|
||||
// Only create mention events for local users, and users for whom the note is visible
|
||||
for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: u.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const isThreadMuted = threadMutings.get(u.id)?.has(threadId);
|
||||
|
||||
const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id));
|
||||
const mutings = userMutings.get(u.id);
|
||||
const isUserMuted = mutings != null && isUserRelated(note, mutings);
|
||||
|
||||
if (isThreadMuted || muted) {
|
||||
if (isThreadMuted || isUserMuted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -647,18 +647,15 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
if (data.reply) {
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.reply.threadId ?? data.reply.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.reply.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.reply.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
|
|
|
|||
|
|
@ -275,12 +275,8 @@ export class ReactionService {
|
|||
|
||||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
||||
if (note.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: note.userId,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const isThreadMuted = await this.cacheService.threadMutingsCache.fetch(note.userId).then(ms => ms.has(threadId));
|
||||
|
||||
if (!isThreadMuted) {
|
||||
this.notificationService.createNotification(note.userId, 'reaction', {
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export class WebhookTestService {
|
|||
private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||
return {
|
||||
id: note.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
|
|
@ -403,6 +404,10 @@ export class WebhookTestService {
|
|||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
isMutingThread: false,
|
||||
isMutingNote: false,
|
||||
isFavorited: false,
|
||||
isRenoted: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing, NoteFavoritesRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
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 type { OnModuleInit } from '@nestjs/common';
|
||||
|
|
@ -55,6 +56,7 @@ function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
|||
return appearNoteIds;
|
||||
}
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
|
|
@ -96,6 +98,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
@Inject(DI.config)
|
||||
private readonly config: Config,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
//private userEntityService: UserEntityService,
|
||||
//private driveFileEntityService: DriveFileEntityService,
|
||||
//private customEmojiService: CustomEmojiService,
|
||||
|
|
@ -131,9 +137,21 @@ 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>();
|
||||
|
||||
// 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,
|
||||
})));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
|
||||
myFollowing?: ReadonlyMap<string, unknown>,
|
||||
myFollowing?: ReadonlyMap<string, Omit<MiFollowing, 'isFollowerHibernated'>> | ReadonlySet<string>,
|
||||
myBlockers?: ReadonlySet<string>,
|
||||
}): Promise<void> {
|
||||
if (meId === packedNote.userId) return;
|
||||
|
|
@ -275,6 +293,142 @@ export class NoteEntityService implements OnModuleInit {
|
|||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyNoteMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
|
||||
const mutedNotes = await this.cacheService.noteMutingsCache.fetch(meId);
|
||||
|
||||
const mutedIds = notes
|
||||
.filter(note => mutedNotes.has(note.id))
|
||||
.map(note => note.id);
|
||||
return new Set(mutedIds);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyTheadMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
|
||||
const mutedThreads = await this.cacheService.threadMutingsCache.fetch(meId);
|
||||
|
||||
const mutedIds = notes
|
||||
.filter(note => mutedThreads.has(note.threadId))
|
||||
.map(note => note.id);
|
||||
return new Set(mutedIds);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyRenotes(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||
myRenotes: Set<string>;
|
||||
}): Promise<Set<string>> {
|
||||
const fetchedRenotes = new Set<string>();
|
||||
const toFetch = new Set<string>();
|
||||
|
||||
if (_hint_) {
|
||||
for (const note of notes) {
|
||||
if (_hint_.myRenotes.has(note.id)) {
|
||||
fetchedRenotes.add(note.id);
|
||||
} else {
|
||||
toFetch.add(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.size > 0) {
|
||||
const fetched = await this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({
|
||||
userId: meId,
|
||||
renoteId: In(Array.from(toFetch)),
|
||||
})
|
||||
.select('note.renoteId', 'renoteId')
|
||||
.getRawMany<{ renoteId: string }>();
|
||||
|
||||
for (const { renoteId } of fetched) {
|
||||
fetchedRenotes.add(renoteId);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedRenotes;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyFavorites(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||
myFavorites: Set<string>;
|
||||
}): Promise<Set<string>> {
|
||||
const fetchedFavorites = new Set<string>();
|
||||
const toFetch = new Set<string>();
|
||||
|
||||
if (_hint_) {
|
||||
for (const note of notes) {
|
||||
if (_hint_.myFavorites.has(note.id)) {
|
||||
fetchedFavorites.add(note.id);
|
||||
} else {
|
||||
toFetch.add(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.size > 0) {
|
||||
const fetched = await this.noteFavoritesRepository.find({
|
||||
where: {
|
||||
userId: meId,
|
||||
noteId: In(Array.from(toFetch)),
|
||||
},
|
||||
select: {
|
||||
noteId: true,
|
||||
},
|
||||
}) as { noteId: string }[];
|
||||
|
||||
for (const { noteId } of fetched) {
|
||||
fetchedFavorites.add(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedFavorites;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyReactions(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||
myReactions: Map<MiNote['id'], string | null>;
|
||||
}): Promise<Map<string, string>> {
|
||||
const fetchedReactions = new Map<string, string>();
|
||||
const toFetch = new Set<string>();
|
||||
|
||||
if (_hint_) {
|
||||
for (const note of notes) {
|
||||
const fromHint = _hint_.myReactions.get(note.id);
|
||||
|
||||
// null means we know there's no reaction, so just skip it.
|
||||
if (fromHint === null) continue;
|
||||
|
||||
if (fromHint) {
|
||||
const converted = this.reactionService.convertLegacyReaction(fromHint);
|
||||
fetchedReactions.set(note.id, converted);
|
||||
} else if (Object.values(note.reactions).some(count => count > 0)) {
|
||||
// Note has at least one reaction, so we need to fetch
|
||||
toFetch.add(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.size > 0) {
|
||||
const fetched = await this.noteReactionsRepository.find({
|
||||
where: {
|
||||
userId: meId,
|
||||
noteId: In(Array.from(toFetch)),
|
||||
},
|
||||
select: {
|
||||
noteId: true,
|
||||
reaction: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const { noteId, reaction } of fetched) {
|
||||
const converted = this.reactionService.convertLegacyReaction(reaction);
|
||||
fetchedReactions.set(noteId, converted);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedReactions;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
|
||||
myReactions: Map<MiNote['id'], string | null>;
|
||||
|
|
@ -306,9 +460,14 @@ export class NoteEntityService implements OnModuleInit {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const reaction = await this.noteReactionsRepository.findOneBy({
|
||||
userId: meId,
|
||||
noteId: note.id,
|
||||
const reaction = await this.noteReactionsRepository.findOne({
|
||||
where: {
|
||||
userId: meId,
|
||||
noteId: note.id,
|
||||
},
|
||||
select: {
|
||||
reaction: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (reaction) {
|
||||
|
|
@ -422,6 +581,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
pollVotes: Map<string, Map<string, MiPollVote[]>>;
|
||||
channels: Map<string, MiChannel>;
|
||||
notes: Map<string, MiNote>;
|
||||
mutedThreads: Set<string>;
|
||||
mutedNotes: Set<string>;
|
||||
favoriteNotes: Set<string>;
|
||||
renotedNotes: Set<string>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'Note'>> {
|
||||
|
|
@ -460,8 +623,28 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const packedFiles = options?._hint_?.packedFiles;
|
||||
const packedUsers = options?._hint_?.packedUsers;
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const [mutedThreads, mutedNotes, isFavorited, isRenoted] = await Promise.all([
|
||||
// mutedThreads
|
||||
opts._hint_?.mutedThreads
|
||||
?? (meId ? this.cacheService.threadMutingsCache.fetch(meId) : new Set<string>()),
|
||||
// mutedNotes
|
||||
opts._hint_?.mutedNotes
|
||||
?? (meId ? this.cacheService.noteMutingsCache.fetch(meId) : new Set<string>),
|
||||
// isFavorited
|
||||
opts._hint_?.favoriteNotes.has(note.id)
|
||||
?? (meId ? this.noteFavoritesRepository.existsBy({ userId: meId, noteId: note.id }) : false),
|
||||
// isRenoted
|
||||
opts._hint_?.renotedNotes.has(note.id)
|
||||
?? (meId ? this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ renoteId: note.id, userId: meId })
|
||||
.getExists() : false),
|
||||
]);
|
||||
|
||||
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,
|
||||
|
|
@ -501,6 +684,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
poll: opts._hint_?.polls.get(note.id),
|
||||
myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId),
|
||||
}) : undefined,
|
||||
isMutingThread: mutedThreads.has(threadId),
|
||||
isMutingNote: mutedNotes.has(note.id),
|
||||
isFavorited,
|
||||
isRenoted,
|
||||
|
||||
...(meId && Object.keys(reactions).length > 0 ? {
|
||||
myReaction: this.populateMyReaction({
|
||||
|
|
@ -648,7 +835,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const fileIds = new Set(targetNotes.flatMap(n => n.fileIds));
|
||||
const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions));
|
||||
|
||||
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([
|
||||
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels, mutedThreads, mutedNotes, favoriteNotes, renotedNotes] = await Promise.all([
|
||||
// bufferedReactions & myReactionsMap
|
||||
this.getReactions(targetNotes, me),
|
||||
// packedFiles
|
||||
|
|
@ -659,6 +846,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
// mentionHandles
|
||||
this.getUserHandles(Array.from(mentionedUsers)),
|
||||
// userFollowings
|
||||
// TODO this might be wrong
|
||||
this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)),
|
||||
// userBlockers
|
||||
this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)),
|
||||
|
|
@ -683,6 +871,24 @@ export class NoteEntityService implements OnModuleInit {
|
|||
}, new Map<string, Map<string, MiPollVote[]>>)),
|
||||
// channels
|
||||
this.getChannels(targetNotes),
|
||||
// mutedThreads
|
||||
me ? this.cacheService.threadMutingsCache.fetch(me.id) : new Set<string>(),
|
||||
// mutedNotes
|
||||
me ? this.cacheService.noteMutingsCache.fetch(me.id) : new Set<string>(),
|
||||
// favoriteNotes
|
||||
me ? this.noteFavoritesRepository
|
||||
.createQueryBuilder('favorite')
|
||||
.select('favorite.noteId', 'noteId')
|
||||
.where({ userId: me.id, noteId: In(noteIds) })
|
||||
.getRawMany<{ noteId: string }>()
|
||||
.then(fs => new Set(fs.map(f => f.noteId))) : new Set<string>(),
|
||||
// renotedNotes
|
||||
me ? this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ userId: me.id, renoteId: In(noteIds) })
|
||||
.select('note.renoteId', 'renoteId')
|
||||
.getRawMany<{ renoteId: string }>()
|
||||
.then(ns => new Set(ns.map(n => n.renoteId))) : new Set<string>(),
|
||||
// (not returned)
|
||||
this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)),
|
||||
]);
|
||||
|
|
@ -701,6 +907,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
pollVotes,
|
||||
channels,
|
||||
notes: new Map(targetNotes.map(n => [n.id, n])),
|
||||
mutedThreads,
|
||||
mutedNotes,
|
||||
favoriteNotes,
|
||||
renotedNotes,
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { id } from './util/id.js';
|
|||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('note_thread_muting')
|
||||
@Index(['userId', 'threadId'], { unique: true })
|
||||
@Index(['userId', 'threadId', 'isPostMute'], { unique: true })
|
||||
export class MiNoteThreadMuting {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
|
@ -30,4 +30,10 @@ export class MiNoteThreadMuting {
|
|||
length: 256,
|
||||
})
|
||||
public threadId: string;
|
||||
|
||||
@Column('boolean', {
|
||||
comment: 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.',
|
||||
default: false,
|
||||
})
|
||||
public isPostMute: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ export const packedNoteSchema = {
|
|||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
threadId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -167,6 +173,22 @@ export const packedNoteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
isMutingThread: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isMutingNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isFavorited: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isRenoted: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
emojis: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import * as Redis from 'ioredis';
|
||||
import * as WebSocket from 'ws';
|
||||
import proxyAddr from 'proxy-addr';
|
||||
import ms from 'ms';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, MiAccessToken, MiUser } from '@/models/_.js';
|
||||
import type { UsersRepository, MiAccessToken, MiUser, NoteReactionsRepository, NotesRepository, NoteFavoritesRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
|
@ -22,6 +21,7 @@ import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
|||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/Connection.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
|
|
@ -45,6 +45,16 @@ export class StreamingApiServerService {
|
|||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private readonly noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private readonly notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private readonly noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private authenticateService: AuthenticateService,
|
||||
private channelsService: ChannelsService,
|
||||
|
|
@ -168,6 +178,10 @@ export class StreamingApiServerService {
|
|||
};
|
||||
|
||||
const stream = new MainStreamConnection(
|
||||
this.noteReactionsRepository,
|
||||
this.notesRepository,
|
||||
this.noteFavoritesRepository,
|
||||
this.queryService,
|
||||
this.channelsService,
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { DI } from '@/di-symbols.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { Brackets } from 'typeorm';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes', 'channels'],
|
||||
|
|
@ -83,6 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
|
|
@ -106,6 +107,8 @@ 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,
|
||||
|
|
@ -119,6 +122,13 @@ 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;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -148,6 +158,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -67,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
|
|
@ -90,6 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -147,8 +147,10 @@ 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({
|
||||
|
|
@ -167,6 +169,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
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({
|
||||
|
|
@ -230,6 +236,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -83,6 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
|
|
@ -115,6 +117,8 @@ 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,
|
||||
|
|
@ -139,6 +143,13 @@ 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) {
|
||||
|
|
@ -185,6 +196,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import type { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
|
@ -26,6 +28,14 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isMutedNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isRenoted: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -55,30 +65,39 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId });
|
||||
|
||||
const [favorite, threadMuting] = await Promise.all([
|
||||
this.noteFavoritesRepository.count({
|
||||
const [favorite, threadMuting, noteMuting, renoted] = await Promise.all([
|
||||
// favorite
|
||||
this.noteFavoritesRepository.exists({
|
||||
where: {
|
||||
userId: me.id,
|
||||
noteId: note.id,
|
||||
},
|
||||
take: 1,
|
||||
}),
|
||||
this.noteThreadMutingsRepository.count({
|
||||
where: {
|
||||
userId: me.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
take: 1,
|
||||
}),
|
||||
// treadMuting
|
||||
this.cacheService.threadMutingsCache.fetch(me.id).then(ms => ms.has(note.threadId ?? note.id)),
|
||||
// noteMuting
|
||||
this.cacheService.noteMutingsCache.fetch(me.id).then(ms => ms.has(note.id)),
|
||||
// renoted
|
||||
this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.andWhere({ renoteId: note.id, userId: me.id })
|
||||
.andWhere(qb => this.queryService
|
||||
.andIsRenote(qb, 'note'))
|
||||
.getExists(),
|
||||
]);
|
||||
|
||||
return {
|
||||
isFavorited: favorite !== 0,
|
||||
isMutedThread: threadMuting !== 0,
|
||||
isFavorited: favorite,
|
||||
isMutedThread: threadMuting,
|
||||
isMutedNote: noteMuting,
|
||||
isRenoted: renoted,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -19,9 +20,11 @@ export const meta = {
|
|||
|
||||
kind: 'write:account',
|
||||
|
||||
// Up to 10 calls, then 1/second
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 10,
|
||||
type: 'bucket',
|
||||
size: 10,
|
||||
dripRate: 1000,
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
@ -37,6 +40,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
noteOnly: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['noteId'],
|
||||
} as const;
|
||||
|
|
@ -52,6 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private getterService: GetterService,
|
||||
private idService: IdService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.getterService.getNote(ps.noteId).catch(err => {
|
||||
|
|
@ -59,6 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw err;
|
||||
});
|
||||
|
||||
/*
|
||||
const mutedNotes = await this.notesRepository.find({
|
||||
where: [{
|
||||
id: note.threadId ?? note.id,
|
||||
|
|
@ -66,12 +72,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
threadId: note.threadId ?? note.id,
|
||||
}],
|
||||
});
|
||||
*/
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
await this.noteThreadMutingsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
threadId: note.threadId ?? note.id,
|
||||
threadId: ps.noteOnly ? note.id : threadId,
|
||||
userId: me.id,
|
||||
isPostMute: ps.noteOnly,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.cacheService.threadMutingsCache.refresh(me.id),
|
||||
this.cacheService.noteMutingsCache.refresh(me.id),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { NoteThreadMutingsRepository } from '@/models/_.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -25,10 +26,11 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
// 10 calls per hour (match create)
|
||||
// Up to 20 calls, then 2/second
|
||||
limit: {
|
||||
duration: 1000 * 60 * 60,
|
||||
max: 10,
|
||||
type: 'bucket',
|
||||
size: 20,
|
||||
dripRate: 2000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
noteOnly: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['noteId'],
|
||||
} as const;
|
||||
|
|
@ -47,6 +50,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private getterService: GetterService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.getterService.getNote(ps.noteId).catch(err => {
|
||||
|
|
@ -54,10 +58,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw err;
|
||||
});
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
await this.noteThreadMutingsRepository.delete({
|
||||
threadId: note.threadId ?? note.id,
|
||||
threadId: ps.noteOnly ? note.id : threadId,
|
||||
userId: me.id,
|
||||
isPostMute: ps.noteOnly,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.cacheService.threadMutingsCache.refresh(me.id),
|
||||
this.cacheService.noteMutingsCache.refresh(me.id),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,8 +99,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const [
|
||||
followings,
|
||||
threadMutings,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(me.id),
|
||||
this.cacheService.threadMutingsCache.fetch(me.id),
|
||||
]);
|
||||
|
||||
const timeline = this.fanoutTimelineEndpointService.timeline({
|
||||
|
|
@ -119,6 +121,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
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({
|
||||
|
|
@ -167,6 +171,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import type { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
||||
import type { MiFollowing, MiUserProfile, NoteFavoritesRepository, NoteReactionsRepository, NotesRepository } from '@/models/_.js';
|
||||
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import type { ChannelsService } from './ChannelsService.js';
|
||||
import type { EventEmitter } from 'events';
|
||||
import type Channel from './channel.js';
|
||||
|
|
@ -42,14 +43,23 @@ export default class Connection {
|
|||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
||||
public userMutedInstances: Set<string> = new Set();
|
||||
public userMutedThreads: Set<string> = new Set();
|
||||
public userMutedNotes: Set<string> = new Set();
|
||||
public myRecentReactions: Map<string, string> = new Map();
|
||||
public myRecentRenotes: Set<string> = new Set();
|
||||
public myRecentFavorites: Set<string> = new Set();
|
||||
private fetchIntervalId: NodeJS.Timeout | null = null;
|
||||
private closingConnection = false;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly noteReactionsRepository: NoteReactionsRepository,
|
||||
private readonly notesRepository: NotesRepository,
|
||||
private readonly noteFavoritesRepository: NoteFavoritesRepository,
|
||||
private readonly queryService: QueryService,
|
||||
private channelsService: ChannelsService,
|
||||
private notificationService: NotificationService,
|
||||
private cacheService: CacheService,
|
||||
public readonly cacheService: CacheService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
loggerService: LoggerService,
|
||||
|
||||
|
|
@ -67,13 +77,34 @@ export default class Connection {
|
|||
@bindThis
|
||||
public async fetch() {
|
||||
if (this.user == null) return;
|
||||
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
|
||||
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes, threadMutings, noteMutings, myRecentReactions, myRecentFavorites, myRecentRenotes] = await Promise.all([
|
||||
this.cacheService.userProfileCache.fetch(this.user.id),
|
||||
this.cacheService.userFollowingsCache.fetch(this.user.id),
|
||||
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
|
||||
this.cacheService.userMutingsCache.fetch(this.user.id),
|
||||
this.cacheService.userBlockedCache.fetch(this.user.id),
|
||||
this.cacheService.renoteMutingsCache.fetch(this.user.id),
|
||||
this.cacheService.threadMutingsCache.fetch(this.user.id),
|
||||
this.cacheService.noteMutingsCache.fetch(this.user.id),
|
||||
this.noteReactionsRepository.find({
|
||||
where: { userId: this.user.id },
|
||||
select: { noteId: true, reaction: true },
|
||||
order: { id: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
this.noteFavoritesRepository.find({
|
||||
where: { userId: this.user.id },
|
||||
select: { noteId: true },
|
||||
order: { id: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ userId: this.user.id })
|
||||
.orderBy({ id: 'DESC' })
|
||||
.limit(100)
|
||||
.select('note.renoteId', 'renoteId')
|
||||
.getRawMany<{ renoteId: string }>(),
|
||||
]);
|
||||
this.userProfile = userProfile;
|
||||
this.following = following;
|
||||
|
|
@ -82,6 +113,11 @@ export default class Connection {
|
|||
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
||||
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
||||
this.userMutedInstances = new Set(userProfile.mutedInstances);
|
||||
this.userMutedThreads = threadMutings;
|
||||
this.userMutedNotes = noteMutings;
|
||||
this.myRecentReactions = new Map(myRecentReactions.map(r => [r.noteId, r.reaction]));
|
||||
this.myRecentFavorites = new Set(myRecentFavorites.map(f => f.noteId ));
|
||||
this.myRecentRenotes = new Set(myRecentRenotes.map(r => r.renoteId ));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import { isRenotePacked, isQuotePacked, isPackedPureRenote } from '@/misc/is-ren
|
|||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import type Connection from './Connection.js';
|
||||
import { deepClone } from '@/misc/clone.js';
|
||||
import type Connection from '@/server/api/stream/Connection.js';
|
||||
|
||||
/**
|
||||
* Stream channel
|
||||
|
|
@ -33,18 +34,35 @@ export default abstract class Channel {
|
|||
return this.connection.userProfile;
|
||||
}
|
||||
|
||||
protected get cacheService() {
|
||||
return this.connection.cacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use cacheService.userFollowingsCache to avoid stale data
|
||||
*/
|
||||
protected get following() {
|
||||
return this.connection.following;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO use onChange to keep these in sync?
|
||||
* @deprecated use cacheService.userMutingsCache to avoid stale data
|
||||
*/
|
||||
protected get userIdsWhoMeMuting() {
|
||||
return this.connection.userIdsWhoMeMuting;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use cacheService.renoteMutingsCache to avoid stale data
|
||||
*/
|
||||
protected get userIdsWhoMeMutingRenotes() {
|
||||
return this.connection.userIdsWhoMeMutingRenotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use cacheService.userBlockedCache to avoid stale data
|
||||
*/
|
||||
protected get userIdsWhoBlockingMe() {
|
||||
return this.connection.userIdsWhoBlockingMe;
|
||||
}
|
||||
|
|
@ -53,6 +71,20 @@ export default abstract class Channel {
|
|||
return this.connection.userMutedInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use cacheService.threadMutingsCache to avoid stale data
|
||||
*/
|
||||
protected get userMutedThreads() {
|
||||
return this.connection.userMutedThreads;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use cacheService.noteMutingsCache to avoid stale data
|
||||
*/
|
||||
protected get userMutedNotes() {
|
||||
return this.connection.userMutedNotes;
|
||||
}
|
||||
|
||||
protected get followingChannels() {
|
||||
return this.connection.followingChannels;
|
||||
}
|
||||
|
|
@ -61,6 +93,18 @@ export default abstract class Channel {
|
|||
return this.connection.subscriber;
|
||||
}
|
||||
|
||||
protected get myRecentReactions() {
|
||||
return this.connection.myRecentReactions;
|
||||
}
|
||||
|
||||
protected get myRecentRenotes() {
|
||||
return this.connection.myRecentRenotes;
|
||||
}
|
||||
|
||||
protected get myRecentFavorites() {
|
||||
return this.connection.myRecentFavorites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a note is visible to the current user *excluding* blocks and mutes.
|
||||
*/
|
||||
|
|
@ -94,6 +138,12 @@ export default abstract class Channel {
|
|||
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||
|
||||
// Muted thread
|
||||
if (this.userMutedThreads.has(note.threadId)) return true;
|
||||
|
||||
// Muted note
|
||||
if (this.userMutedNotes.has(note.id)) return true;
|
||||
|
||||
// If it's a boost (pure renote) then we need to check the target as well
|
||||
if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true;
|
||||
|
||||
|
|
@ -104,29 +154,9 @@ export default abstract class Channel {
|
|||
if (!this.following.has(note.userId)) return true;
|
||||
}
|
||||
|
||||
// TODO muted threads
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function modifies {@link note}, please make sure it has been shallow cloned.
|
||||
* See Dakkar's comment of {@link assignMyReaction} for more
|
||||
* @param note The note to change
|
||||
*/
|
||||
protected async hideNote(note: Packed<'Note'>): Promise<void> {
|
||||
if (note.renote) {
|
||||
await this.hideNote(note.renote);
|
||||
}
|
||||
|
||||
if (note.reply) {
|
||||
await this.hideNote(note.reply);
|
||||
}
|
||||
|
||||
const meId = this.user?.id ?? null;
|
||||
await this.noteEntityService.hideNote(note, meId);
|
||||
}
|
||||
|
||||
constructor(id: string, connection: Connection, noteEntityService: NoteEntityService) {
|
||||
this.id = id;
|
||||
this.connection = connection;
|
||||
|
|
@ -153,37 +183,44 @@ export default abstract class Channel {
|
|||
|
||||
public onMessage?(type: string, body: JsonValue): void;
|
||||
|
||||
public async assignMyReaction(note: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||
public async rePackNote(note: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||
// If there's no user, then packing won't change anything.
|
||||
// We can just re-use the original note.
|
||||
if (!this.user) {
|
||||
return note;
|
||||
}
|
||||
|
||||
// StreamingApiServerService creates a single EventEmitter per server process,
|
||||
// so a new note arriving from redis gets de-serialised once per server process,
|
||||
// and then that single object is passed to all active channels on each connection.
|
||||
// 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 = { ...note };
|
||||
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
|
||||
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
|
||||
const myReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
|
||||
if (myReaction) {
|
||||
clonedNote.renote = { ...note.renote };
|
||||
clonedNote.renote.myReaction = myReaction;
|
||||
}
|
||||
}
|
||||
if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) {
|
||||
const myReaction = await this.noteEntityService.populateMyReaction(note.renote.reply, this.user.id);
|
||||
if (myReaction) {
|
||||
clonedNote.renote = { ...note.renote };
|
||||
clonedNote.renote.reply = { ...note.renote.reply };
|
||||
clonedNote.renote.reply.myReaction = myReaction;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) {
|
||||
const myReaction = await this.noteEntityService.populateMyReaction(note.reply, this.user.id);
|
||||
if (myReaction) {
|
||||
clonedNote.reply = { ...note.reply };
|
||||
clonedNote.reply.myReaction = myReaction;
|
||||
}
|
||||
}
|
||||
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 [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings] = await Promise.all([
|
||||
this.noteEntityService.populateMyReactions(notes, this.user.id, {
|
||||
myReactions: this.myRecentReactions,
|
||||
}),
|
||||
this.noteEntityService.populateMyRenotes(notes, this.user.id, {
|
||||
myRenotes: this.myRecentRenotes,
|
||||
}),
|
||||
this.noteEntityService.populateMyFavorites(notes, this.user.id, {
|
||||
myFavorites: this.myRecentFavorites,
|
||||
}),
|
||||
this.noteEntityService.populateMyTheadMutings(notes, this.user.id),
|
||||
this.noteEntityService.populateMyNoteMutings(notes, 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);
|
||||
|
||||
return clonedNote;
|
||||
}
|
||||
}
|
||||
|
|
@ -194,3 +231,21 @@ export type MiChannelService<T extends boolean> = {
|
|||
kind: T extends true ? string : string | null | undefined;
|
||||
create: (id: string, connection: Connection) => Channel;
|
||||
};
|
||||
|
||||
function crawl(note: Packed<'Note'>, into?: Packed<'Note'>[]): Packed<'Note'>[] {
|
||||
into ??= [];
|
||||
|
||||
if (!into.includes(note)) {
|
||||
into.push(note);
|
||||
}
|
||||
|
||||
if (note.reply) {
|
||||
crawl(note.reply, into);
|
||||
}
|
||||
|
||||
if (note.renote) {
|
||||
crawl(note.renote, into);
|
||||
}
|
||||
|
||||
return into;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,9 +78,7 @@ class BubbleTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,9 +49,7 @@ class ChannelChannel extends Channel {
|
|||
|
||||
if (this.isNoteMutedOrBlocked(note)) return;
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,9 +79,7 @@ class GlobalTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,7 @@ class HashtagChannel extends Channel {
|
|||
|
||||
if (this.isNoteMutedOrBlocked(note)) return;
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,9 +73,7 @@ class HomeTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,9 +90,7 @@ class HybridTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,7 @@ class LocalTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,9 +70,7 @@ class RoleTimelineChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
} else {
|
||||
this.send(data.type, data.body);
|
||||
|
|
|
|||
|
|
@ -114,9 +114,7 @@ class UserListChannel extends Channel {
|
|||
}
|
||||
}
|
||||
|
||||
const clonedNote = await this.assignMyReaction(note);
|
||||
await this.hideNote(clonedNote);
|
||||
|
||||
const clonedNote = await this.rePackNote(note);
|
||||
this.send('note', clonedNote);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@ export class ClientServerService {
|
|||
relations: ['user'],
|
||||
});
|
||||
|
||||
// TODO pack with current user, or the frontend can get bad data
|
||||
if (note && !note.user!.requireSigninToViewContents) {
|
||||
const _note = await this.noteEntityService.pack(note);
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import * as Redis from 'ioredis';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { FakeInternalEventService } from './FakeInternalEventService.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, NoteThreadMutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js';
|
||||
|
|
@ -50,6 +50,9 @@ export class NoOpCacheService extends CacheService {
|
|||
@Inject(DI.followingsRepository)
|
||||
followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
@Inject(UserEntityService)
|
||||
userEntityService: UserEntityService,
|
||||
) {
|
||||
|
|
@ -65,6 +68,7 @@ export class NoOpCacheService extends CacheService {
|
|||
blockingsRepository,
|
||||
renoteMutingsRepository,
|
||||
followingsRepository,
|
||||
noteThreadMutingsRepository,
|
||||
userEntityService,
|
||||
fakeInternalEventService,
|
||||
);
|
||||
|
|
@ -82,6 +86,8 @@ export class NoOpCacheService extends CacheService {
|
|||
this.userBlockingCache = NoOpQuantumKVCache.copy(this.userBlockingCache, fakeInternalEventService);
|
||||
this.userBlockedCache = NoOpQuantumKVCache.copy(this.userBlockedCache, fakeInternalEventService);
|
||||
this.renoteMutingsCache = NoOpQuantumKVCache.copy(this.renoteMutingsCache, fakeInternalEventService);
|
||||
this.threadMutingsCache = NoOpQuantumKVCache.copy(this.threadMutingsCache, fakeInternalEventService);
|
||||
this.noteMutingsCache = NoOpQuantumKVCache.copy(this.noteMutingsCache, fakeInternalEventService);
|
||||
this.userFollowingsCache = NoOpQuantumKVCache.copy(this.userFollowingsCache, fakeInternalEventService);
|
||||
this.userFollowersCache = NoOpQuantumKVCache.copy(this.userFollowersCache, fakeInternalEventService);
|
||||
this.hibernatedUserCache = NoOpQuantumKVCache.copy(this.hibernatedUserCache, fakeInternalEventService);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.details }}</template>
|
||||
<div class="_gaps_s">
|
||||
<Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/>
|
||||
<SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
|
||||
<div class="_gaps_s" @click.stop>
|
||||
<SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div
|
||||
v-if="!hardMuted && muted === false"
|
||||
v-if="!hardMuted && muted === false && !threadMuted && !noteMuted"
|
||||
v-show="!isDeleted"
|
||||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
|
|
@ -95,8 +95,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/>
|
||||
</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">
|
||||
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @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"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
|
||||
|
|
@ -125,9 +125,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-tooltip="renoteTooltip"
|
||||
:class="$style.footerButton"
|
||||
class="_button"
|
||||
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
:style="appearNote.isRenoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@click.stop
|
||||
@mousedown.prevent="renoted ? undoRenote(appearNote) : boostVisibility($event.shiftKey)"
|
||||
@mousedown.prevent="appearNote.isRenoted ? undoRenote(appearNote) : boostVisibility($event.shiftKey)"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -168,8 +168,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
<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>
|
||||
<!--
|
||||
|
|
@ -311,8 +311,7 @@ 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 renoted = ref(false);
|
||||
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
|
||||
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);
|
||||
|
|
@ -320,7 +319,9 @@ const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.vi
|
|||
const renoteCollapsed = ref(
|
||||
prefer.s.collapseRenotes && isRenote && (
|
||||
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
(appearNote.value.myReaction != null)
|
||||
(appearNote.value.myReaction != null) ||
|
||||
(appearNote.value.isFavorited) ||
|
||||
(appearNote.value.isRenoted)
|
||||
),
|
||||
);
|
||||
const inReplyToCollapsed = ref(prefer.s.collapseNotesRepliedTo);
|
||||
|
|
@ -335,7 +336,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
|||
|
||||
const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
const renoteTooltip = computeRenoteTooltip(appearNote);
|
||||
|
||||
let renoting = false;
|
||||
|
||||
|
|
@ -350,7 +351,7 @@ const keymap = {
|
|||
},
|
||||
'q': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost);
|
||||
if (canRenote.value && !appearNote.value.isRenoted && !renoting) renote(prefer.s.visibilityOnBoost);
|
||||
},
|
||||
'm': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
|
|
@ -460,16 +461,6 @@ if (!props.mock) {
|
|||
});
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
userId: $i.id,
|
||||
limit: 1,
|
||||
}).then((res) => {
|
||||
renoted.value = res.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
|
|
@ -528,7 +519,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
channelId: appearNote.value.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
}
|
||||
} else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) {
|
||||
|
|
@ -549,7 +540,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
renoteId: appearNote.value.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
}
|
||||
}
|
||||
|
|
@ -728,7 +719,7 @@ function undoRenote(note) : void {
|
|||
noteId: note.id,
|
||||
});
|
||||
os.toast(i18n.ts.rmboost);
|
||||
renoted.value = false;
|
||||
appearNote.value.isRenoted = false;
|
||||
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div
|
||||
v-if="!muted"
|
||||
v-if="!muted && !threadMuted && !noteMuted"
|
||||
v-show="!isDeleted"
|
||||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
|
|
@ -111,8 +111,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</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">
|
||||
<SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
|
||||
<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"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
|
||||
</div>
|
||||
|
|
@ -138,8 +138,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-tooltip="renoteTooltip"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@mousedown.prevent="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
:style="appearNote.isRenoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@mousedown.prevent="appearNote.isRenoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -226,8 +226,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
<div v-else class="_panel" :class="$style.muted" @click.stop="muted = false">
|
||||
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -336,7 +336,6 @@ const galleryEl = useTemplateRef('galleryEl');
|
|||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
|
||||
|
|
@ -352,24 +351,14 @@ const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
|
|||
|
||||
const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
const renoteTooltip = computeRenoteTooltip(appearNote);
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
const { muted, threadMuted, noteMuted } = checkMutes(appearNote);
|
||||
|
||||
watch(() => props.expandAllCws, (expandAllCws) => {
|
||||
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
userId: $i.id,
|
||||
limit: 1,
|
||||
}).then((res) => {
|
||||
renoted.value = res.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
let renoting = false;
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
|
|
@ -380,7 +369,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
|||
const keymap = {
|
||||
'r': () => reply(),
|
||||
'e|a|plus': () => react(),
|
||||
'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost); },
|
||||
'q': () => { if (canRenote.value && !appearNote.value.isRenoted && !renoting) renote(prefer.s.visibilityOnBoost); },
|
||||
'm': () => showMenu(),
|
||||
'c': () => {
|
||||
if (!prefer.s.showClipButtonInNoteFooter) return;
|
||||
|
|
@ -554,7 +543,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
channelId: appearNote.value.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
} else {
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
|
|
@ -573,7 +562,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
renoteId: appearNote.value.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
}
|
||||
}
|
||||
|
|
@ -721,12 +710,12 @@ function undoReact(targetNote: Misskey.entities.Note): void {
|
|||
}
|
||||
|
||||
function undoRenote() : void {
|
||||
if (!renoted.value) return;
|
||||
if (!appearNote.value.isRenoted) return;
|
||||
misskeyApi('notes/unrenote', {
|
||||
noteId: appearNote.value.id,
|
||||
});
|
||||
os.toast(i18n.ts.rmboost);
|
||||
renoted.value = false;
|
||||
appearNote.value.isRenoted = false;
|
||||
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div v-show="!isDeleted" v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]">
|
||||
<div v-show="!isDeleted" v-if="!muted && !noteMuted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]">
|
||||
<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/>
|
||||
|
|
@ -31,8 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-tooltip="renoteTooltip"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
:style="appearNote.isRenoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@click.stop="appearNote.isRenoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
>
|
||||
<i class="ph-rocket-launch ph-bold ph-lg"></i>
|
||||
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
|
||||
|
|
@ -78,8 +78,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="muted = false">
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
<div v-else :class="$style.muted" @click.stop="muted = false">
|
||||
<SkMutedNote :muted="muted" :threadMuted="false" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -137,7 +137,6 @@ const el = shallowRef<HTMLElement>();
|
|||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const reactButton = shallowRef<HTMLElement>();
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const renoteButton = shallowRef<HTMLElement>();
|
||||
|
|
@ -145,7 +144,7 @@ const quoteButton = shallowRef<HTMLElement>();
|
|||
const menuButton = shallowRef<HTMLElement>();
|
||||
const likeButton = shallowRef<HTMLElement>();
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
const renoteTooltip = computeRenoteTooltip(appearNote);
|
||||
|
||||
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
|
|
@ -172,7 +171,7 @@ async function removeReply(id: Misskey.entities.Note['id']) {
|
|||
}
|
||||
}
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
const { muted, noteMuted } = checkMutes(appearNote);
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
|
|
@ -183,16 +182,6 @@ useNoteCapture({
|
|||
onDeleteCallback: props.detail && props.depth < prefer.s.numberOfReplies ? props.onDeleteCallback : undefined,
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
userId: $i.id,
|
||||
limit: 1,
|
||||
}).then((res) => {
|
||||
renoted.value = res.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function focus() {
|
||||
el.value?.focus();
|
||||
}
|
||||
|
|
@ -270,12 +259,12 @@ function undoReact(note): void {
|
|||
}
|
||||
|
||||
function undoRenote() : void {
|
||||
if (!renoted.value) return;
|
||||
if (!appearNote.value.isRenoted) return;
|
||||
misskeyApi('notes/unrenote', {
|
||||
noteId: appearNote.value.id,
|
||||
});
|
||||
os.toast(i18n.ts.rmboost);
|
||||
renoted.value = false;
|
||||
appearNote.value.isRenoted = false;
|
||||
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
|
@ -322,7 +311,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
channelId: appearNote.value.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
});
|
||||
} else {
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
|
|
@ -341,7 +330,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
visibility: visibility,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkA>
|
||||
</header>
|
||||
<div>
|
||||
<div v-if="muted" :class="[$style.text, $style.muted]">
|
||||
<SkMutedNote :muted="muted" :note="note"></SkMutedNote>
|
||||
<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"/>
|
||||
</div>
|
||||
|
|
@ -35,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<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';
|
||||
|
|
@ -49,8 +50,7 @@ defineEmits<{
|
|||
(event: 'select', user: Misskey.entities.UserLite): void
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const { muted, hardMuted } = checkMutes(props.note);
|
||||
const { muted, hardMuted, threadMuted, noteMuted } = checkMutes(computed(() => props.note));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -4,24 +4,39 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<I18n v-if="noteMuted" :src="i18n.ts.userSaysSomethingInMutedNote" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else-if="!prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<I18n v-else-if="threadMuted" :src="i18n.ts.userSaysSomethingInMutedThread" 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>
|
||||
|
||||
<br v-if="threadMuted && muted">
|
||||
|
||||
<template v-if="muted">
|
||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||
<template #name>
|
||||
<MkUserName :user="note.user"/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -30,10 +45,15 @@ import { computed } from 'vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
muted: false | 'sensitiveMute' | string[];
|
||||
threadMuted?: boolean;
|
||||
noteMuted?: boolean;
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
}>(), {
|
||||
threadMuted: false,
|
||||
noteMuted: false,
|
||||
});
|
||||
|
||||
const mutedWords = computed(() => Array.isArray(props.muted)
|
||||
? props.muted.join(', ')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div
|
||||
v-if="!hardMuted && muted === false"
|
||||
v-if="!hardMuted && muted === false && !threadMuted && !noteMuted"
|
||||
v-show="!isDeleted"
|
||||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
|
|
@ -96,8 +96,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/>
|
||||
</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">
|
||||
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @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"/>
|
||||
</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">
|
||||
|
|
@ -125,9 +125,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-tooltip="renoteTooltip"
|
||||
:class="$style.footerButton"
|
||||
class="_button"
|
||||
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
:style="appearNote.isRenoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@click.stop
|
||||
@mousedown.prevent="renoted ? undoRenote(appearNote) : boostVisibility($event.shiftKey)"
|
||||
@mousedown.prevent="appearNote.isRenoted ? undoRenote(appearNote) : boostVisibility($event.shiftKey)"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -168,8 +168,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
<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>
|
||||
<!--
|
||||
|
|
@ -310,8 +310,7 @@ 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 renoted = ref(false);
|
||||
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
|
||||
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);
|
||||
|
|
@ -319,7 +318,9 @@ const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.vi
|
|||
const renoteCollapsed = ref(
|
||||
prefer.s.collapseRenotes && isRenote && (
|
||||
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
(appearNote.value.myReaction != null)
|
||||
(appearNote.value.myReaction != null) ||
|
||||
(appearNote.value.isFavorited) ||
|
||||
(appearNote.value.isRenoted)
|
||||
),
|
||||
);
|
||||
const inReplyToCollapsed = ref(prefer.s.collapseNotesRepliedTo);
|
||||
|
|
@ -334,7 +335,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
|||
|
||||
const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
const renoteTooltip = computeRenoteTooltip(appearNote);
|
||||
|
||||
let renoting = false;
|
||||
|
||||
|
|
@ -349,7 +350,7 @@ const keymap = {
|
|||
},
|
||||
'q': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost);
|
||||
if (canRenote.value && !appearNote.value.isRenoted && !renoting) renote(prefer.s.visibilityOnBoost);
|
||||
},
|
||||
'm': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
|
|
@ -459,16 +460,6 @@ if (!props.mock) {
|
|||
});
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
userId: $i.id,
|
||||
limit: 1,
|
||||
}).then((res) => {
|
||||
renoted.value = res.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
|
|
@ -527,7 +518,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
channelId: appearNote.value.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
}
|
||||
} else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) {
|
||||
|
|
@ -548,7 +539,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
renoteId: appearNote.value.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
}
|
||||
}
|
||||
|
|
@ -727,7 +718,7 @@ function undoRenote(note) : void {
|
|||
noteId: note.id,
|
||||
});
|
||||
os.toast(i18n.ts.rmboost);
|
||||
renoted.value = false;
|
||||
appearNote.value.isRenoted = false;
|
||||
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div
|
||||
v-if="!muted"
|
||||
v-if="!muted && !threadMuted && !noteMuted"
|
||||
v-show="!isDeleted"
|
||||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
|
|
@ -115,8 +115,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</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">
|
||||
<SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
|
||||
<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"/>
|
||||
</div>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
|
||||
</div>
|
||||
|
|
@ -142,8 +142,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-tooltip="renoteTooltip"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@mousedown.prevent="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
:style="appearNote.isRenoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@mousedown.prevent="appearNote.isRenoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -230,8 +230,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
<div v-else class="_panel" :class="$style.muted" @click.stop="muted = false">
|
||||
<SkMutedNote :muted="muted" :threadMuted="threadMuted" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -341,7 +341,6 @@ const galleryEl = useTemplateRef('galleryEl');
|
|||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(prefer.s.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
|
||||
|
|
@ -357,24 +356,14 @@ const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
|
|||
|
||||
const mergedCW = computed(() => computeMergedCw(appearNote.value));
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
const renoteTooltip = computeRenoteTooltip(appearNote);
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
const { muted, threadMuted, noteMuted } = checkMutes(appearNote);
|
||||
|
||||
watch(() => props.expandAllCws, (expandAllCws) => {
|
||||
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
userId: $i.id,
|
||||
limit: 1,
|
||||
}).then((res) => {
|
||||
renoted.value = res.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
let renoting = false;
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
|
|
@ -385,7 +374,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
|||
const keymap = {
|
||||
'r': () => reply(),
|
||||
'e|a|plus': () => react(),
|
||||
'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(prefer.s.visibilityOnBoost); },
|
||||
'q': () => { if (canRenote.value && !appearNote.value.isRenoted && !renoting) renote(prefer.s.visibilityOnBoost); },
|
||||
'm': () => showMenu(),
|
||||
'c': () => {
|
||||
if (!prefer.s.showClipButtonInNoteFooter) return;
|
||||
|
|
@ -559,7 +548,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
channelId: appearNote.value.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
} else {
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
|
|
@ -578,7 +567,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
renoteId: appearNote.value.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
}).finally(() => { renoting = false; });
|
||||
}
|
||||
}
|
||||
|
|
@ -726,12 +715,12 @@ function undoReact(targetNote: Misskey.entities.Note): void {
|
|||
}
|
||||
|
||||
function undoRenote() : void {
|
||||
if (!renoted.value) return;
|
||||
if (!appearNote.value.isRenoted) return;
|
||||
misskeyApi('notes/unrenote', {
|
||||
noteId: appearNote.value.id,
|
||||
});
|
||||
os.toast(i18n.ts.rmboost);
|
||||
renoted.value = false;
|
||||
appearNote.value.isRenoted = false;
|
||||
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div v-show="!isDeleted" v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1, [$style.isReply]: props.isReply, [$style.detailed]: props.detailed }]">
|
||||
<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 }]">
|
||||
<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>
|
||||
|
|
@ -39,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-tooltip="renoteTooltip"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
:style="appearNote.isRenoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
|
||||
@click.stop="appearNote.isRenoted ? undoRenote() : boostVisibility($event.shiftKey)"
|
||||
>
|
||||
<i class="ph-rocket-launch ph-bold ph-lg"></i>
|
||||
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
|
||||
|
|
@ -86,8 +86,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="muted = false">
|
||||
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
|
||||
<div v-else :class="$style.muted" @click.stop="muted = false">
|
||||
<SkMutedNote :muted="muted" :threadMuted="false" :noteMuted="noteMuted" :note="appearNote"></SkMutedNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -151,7 +151,6 @@ const el = shallowRef<HTMLElement>();
|
|||
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
|
||||
const translating = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const renoted = ref(false);
|
||||
const reactButton = shallowRef<HTMLElement>();
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const renoteButton = shallowRef<HTMLElement>();
|
||||
|
|
@ -159,7 +158,7 @@ const quoteButton = shallowRef<HTMLElement>();
|
|||
const menuButton = shallowRef<HTMLElement>();
|
||||
const likeButton = shallowRef<HTMLElement>();
|
||||
|
||||
const renoteTooltip = computeRenoteTooltip(renoted);
|
||||
const renoteTooltip = computeRenoteTooltip(appearNote);
|
||||
|
||||
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
|
|
@ -186,7 +185,7 @@ async function removeReply(id: Misskey.entities.Note['id']) {
|
|||
}
|
||||
}
|
||||
|
||||
const { muted } = checkMutes(appearNote.value);
|
||||
const { muted, noteMuted } = checkMutes(appearNote);
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
|
|
@ -197,16 +196,6 @@ useNoteCapture({
|
|||
onDeleteCallback: props.detail && props.depth < prefer.s.numberOfReplies ? props.onDeleteCallback : undefined,
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
userId: $i.id,
|
||||
limit: 1,
|
||||
}).then((res) => {
|
||||
renoted.value = res.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function focus() {
|
||||
el.value?.focus();
|
||||
}
|
||||
|
|
@ -284,12 +273,12 @@ function undoReact(note): void {
|
|||
}
|
||||
|
||||
function undoRenote() : void {
|
||||
if (!renoted.value) return;
|
||||
if (!appearNote.value.isRenoted) return;
|
||||
misskeyApi('notes/unrenote', {
|
||||
noteId: appearNote.value.id,
|
||||
});
|
||||
os.toast(i18n.ts.rmboost);
|
||||
renoted.value = false;
|
||||
appearNote.value.isRenoted = false;
|
||||
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
|
|
@ -336,7 +325,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
channelId: appearNote.value.channelId,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
});
|
||||
} else {
|
||||
const el = renoteButton.value as HTMLElement | null | undefined;
|
||||
|
|
@ -355,7 +344,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
visibility: visibility,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
renoted.value = true;
|
||||
appearNote.value.isRenoted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
</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"/>
|
||||
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -93,10 +93,9 @@ const urls = computed<string[]>(() => {
|
|||
return [];
|
||||
});
|
||||
|
||||
// todo un-ref these
|
||||
const isRefreshing = ref<Promise<void> | false>(false);
|
||||
const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
|
||||
const cachedPreviews = ref(new Map<string, Summary | null>());
|
||||
const cachedNotes = new Map<string, Misskey.entities.Note | null>();
|
||||
const cachedPreviews = new Map<string, Summary | null>();
|
||||
const cachedUsers = new Map<string, Misskey.entities.User | null>();
|
||||
|
||||
/**
|
||||
|
|
@ -151,7 +150,7 @@ async function fetchPreviews(): Promise<Summary[]> {
|
|||
}
|
||||
|
||||
async function fetchPreview(url: string): Promise<Summary | null> {
|
||||
const cached = cachedPreviews.value.get(url);
|
||||
const cached = cachedPreviews.get(url);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
|
@ -163,15 +162,15 @@ async function fetchPreview(url: string): Promise<Summary | null> {
|
|||
if (res?.ok) {
|
||||
// Success - got the summary
|
||||
const summary: Summary = await res.json();
|
||||
cachedPreviews.value.set(url, summary);
|
||||
cachedPreviews.set(url, summary);
|
||||
if (summary.url !== url) {
|
||||
cachedPreviews.value.set(summary.url, summary);
|
||||
cachedPreviews.set(summary.url, summary);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Failed, blocked, or not found
|
||||
cachedPreviews.value.set(url, null);
|
||||
cachedPreviews.set(url, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +186,7 @@ async function attachNote(summary: Summary, noteLimiter: Limiter<Misskey.entitie
|
|||
}
|
||||
|
||||
async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
|
||||
const cached = cachedNotes.value.get(noteUri);
|
||||
const cached = cachedNotes.get(noteUri);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
|
@ -197,15 +196,15 @@ async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null>
|
|||
const note = response['object'];
|
||||
|
||||
// Success - got the note
|
||||
cachedNotes.value.set(noteUri, note);
|
||||
cachedNotes.set(noteUri, note);
|
||||
if (note.uri && note.uri !== noteUri) {
|
||||
cachedNotes.value.set(note.uri, note);
|
||||
cachedNotes.set(note.uri, note);
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
// Failed, blocked, or not found
|
||||
cachedNotes.value.set(noteUri, null);
|
||||
cachedNotes.set(noteUri, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div class="_gaps" :class="$style.textRoot">
|
||||
<Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/>
|
||||
<div v-if="isEnabledUrlPreview" class="_gaps_s">
|
||||
<SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes" @click.stop/>
|
||||
<div v-if="isEnabledUrlPreview" class="_gaps_s" @click.stop>
|
||||
<SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
/>
|
||||
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
|
||||
</MkFukidashi>
|
||||
<SkUrlPreviewGroup :sourceNodes="parsed" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/>
|
||||
<div class="_gaps_s" style="margin: 8px 0;" @click.stop>
|
||||
<SkUrlPreviewGroup :sourceNodes="parsed" :showAsQuote="!message.fromUser.rejectQuotes"/>
|
||||
</div>
|
||||
<div :class="$style.footer">
|
||||
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
|
||||
<MkTime :class="$style.time" :time="message.createdAt"/>
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ export function boostMenuItems(appearNote: Ref<Misskey.entities.Note>, renote: (
|
|||
];
|
||||
}
|
||||
|
||||
export function computeRenoteTooltip(renoted: Ref<boolean>): ComputedRef<string> {
|
||||
export function computeRenoteTooltip(note: ComputedRef<Misskey.entities.Note>): ComputedRef<string> {
|
||||
return computed(() => {
|
||||
if (renoted.value) return i18n.ts.unrenote;
|
||||
if (note.value.isRenoted) return i18n.ts.unrenote;
|
||||
if (prefer.s.showVisibilitySelectorOnBoost) return i18n.ts.renote;
|
||||
return i18n.ts.renoteShift;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,14 +3,39 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { inject, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import type { Ref, ComputedRef } from 'vue';
|
||||
import { $i } from '@/i';
|
||||
|
||||
export function checkMutes(noteToCheck: Misskey.entities.Note, withHardMute = false) {
|
||||
const muted = ref(checkMute(noteToCheck, $i?.mutedWords));
|
||||
const hardMuted = ref(withHardMute && checkMute(noteToCheck, $i?.hardMutedWords, true));
|
||||
return { muted, hardMuted };
|
||||
export function checkMutes(noteToCheck: ComputedRef<Misskey.entities.Note>, withHardMute?: ComputedRef<boolean>) {
|
||||
const muteEnable = ref(true);
|
||||
|
||||
const muted = computed<false | string[], boolean>({
|
||||
get() {
|
||||
if (!muteEnable.value) return false;
|
||||
return checkMute(noteToCheck.value, $i?.mutedWords);
|
||||
},
|
||||
set(value: boolean) {
|
||||
muteEnable.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
const threadMuted = computed(() => {
|
||||
if (!muteEnable.value) return false;
|
||||
return noteToCheck.value.isMutingThread;
|
||||
});
|
||||
|
||||
const noteMuted = computed(() => {
|
||||
if (!muteEnable.value) return false;
|
||||
return noteToCheck.value.isMutingNote;
|
||||
});
|
||||
|
||||
const hardMuted = computed(() => {
|
||||
if (!withHardMute?.value) return false;
|
||||
return checkMute(noteToCheck.value, $i?.hardMutedWords, true);
|
||||
});
|
||||
|
||||
return { muted, hardMuted, threadMuted, noteMuted };
|
||||
}
|
||||
|
||||
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null): false;
|
||||
|
|
|
|||
|
|
@ -213,9 +213,9 @@ export function getNoteMenu(props: {
|
|||
noteId: appearNote.id,
|
||||
});
|
||||
|
||||
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
|
||||
os.post({ initialNote: appearNote, renote: appearNote.renote ?? undefined, reply: appearNote.reply ?? undefined, channel: appearNote.channel });
|
||||
|
||||
if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
|
||||
if ($i && Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
|
||||
claimAchievement('noteDeletedWithin1min');
|
||||
}
|
||||
});
|
||||
|
|
@ -224,8 +224,8 @@ export function getNoteMenu(props: {
|
|||
function edit(): void {
|
||||
os.post({
|
||||
initialNote: appearNote,
|
||||
renote: appearNote.renote,
|
||||
reply: appearNote.reply,
|
||||
renote: appearNote.renote ?? undefined,
|
||||
reply: appearNote.reply ?? undefined,
|
||||
channel: appearNote.channel,
|
||||
editId: appearNote.id,
|
||||
initialFiles: appearNote.files,
|
||||
|
|
@ -242,6 +242,14 @@ export function getNoteMenu(props: {
|
|||
function toggleThreadMute(mute: boolean): void {
|
||||
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
|
||||
noteId: appearNote.id,
|
||||
noteOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNoteMute(mute: boolean): void {
|
||||
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
|
||||
noteId: appearNote.id,
|
||||
noteOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -293,9 +301,11 @@ export function getNoteMenu(props: {
|
|||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if ($i) {
|
||||
/*
|
||||
const statePromise = misskeyApi('notes/state', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
*/
|
||||
|
||||
if (props.currentClip?.userId === $i.id) {
|
||||
menuItems.push({
|
||||
|
|
@ -352,15 +362,19 @@ export function getNoteMenu(props: {
|
|||
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
menuItems.push(statePromise.then(state => state.isFavorited ? {
|
||||
icon: 'ti ti-star-off',
|
||||
text: i18n.ts.unfavorite,
|
||||
action: () => toggleFavorite(false),
|
||||
} : {
|
||||
icon: 'ti ti-star',
|
||||
text: i18n.ts.favorite,
|
||||
action: () => toggleFavorite(true),
|
||||
}));
|
||||
if (appearNote.isFavorited) {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-star-off',
|
||||
text: i18n.ts.unfavorite,
|
||||
action: () => toggleFavorite(false),
|
||||
});
|
||||
} else {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-star',
|
||||
text: i18n.ts.favorite,
|
||||
action: () => toggleFavorite(true),
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
type: 'parent',
|
||||
|
|
@ -369,15 +383,33 @@ export function getNoteMenu(props: {
|
|||
children: () => getNoteClipMenu(props),
|
||||
});
|
||||
|
||||
menuItems.push(statePromise.then(state => state.isMutedThread ? {
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.unmuteThread,
|
||||
action: () => toggleThreadMute(false),
|
||||
} : {
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.muteThread,
|
||||
action: () => toggleThreadMute(true),
|
||||
}));
|
||||
if (appearNote.isMutingThread) {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.unmuteThread,
|
||||
action: () => toggleThreadMute(false),
|
||||
});
|
||||
} else {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.muteThread,
|
||||
action: () => toggleThreadMute(true),
|
||||
});
|
||||
}
|
||||
|
||||
if (appearNote.isMutingNote) {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.unmuteNote,
|
||||
action: () => toggleNoteMute(false),
|
||||
});
|
||||
} else {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.muteNote,
|
||||
action: () => toggleNoteMute(true),
|
||||
});
|
||||
}
|
||||
|
||||
if (appearNote.userId === $i.id) {
|
||||
if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
|
||||
|
|
|
|||
|
|
@ -4652,6 +4652,11 @@ export type components = {
|
|||
* @example xxxxxxxxxx
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Format: id
|
||||
* @example xxxxxxxxxx
|
||||
*/
|
||||
threadId: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
|
|
@ -4696,6 +4701,10 @@ export type components = {
|
|||
votes: number;
|
||||
}[];
|
||||
}) | null;
|
||||
isMutingThread: boolean;
|
||||
isMutingNote: boolean;
|
||||
isFavorited: boolean;
|
||||
isRenoted: boolean;
|
||||
emojis?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
|
@ -28514,6 +28523,8 @@ export type operations = {
|
|||
'application/json': {
|
||||
isFavorited: boolean;
|
||||
isMutedThread: boolean;
|
||||
isMutedNote: boolean;
|
||||
isRenoted: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -28567,6 +28578,8 @@ export type operations = {
|
|||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
noteId: string;
|
||||
/** @default false */
|
||||
noteOnly?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -28625,6 +28638,8 @@ export type operations = {
|
|||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
noteId: string;
|
||||
/** @default false */
|
||||
noteOnly?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ muted: "Muted"
|
|||
renoteMute: "Mute Boosts"
|
||||
renoteMuted: "Boosts muted"
|
||||
renoteUnmute: "Unmute Boosts"
|
||||
muteNote: "Mute note"
|
||||
unmuteNote: "Unmute note"
|
||||
userSaysSomethingInMutedNote: "{name} said something in a muted post"
|
||||
userSaysSomethingInMutedThread: "{name} said something in a muted thread"
|
||||
markAsNSFW: "Mark all media from user as NSFW"
|
||||
markInstanceAsNSFW: "Mark as NSFW"
|
||||
nsfwConfirm: "Are you sure that you want to mark all media from this account as NSFW?"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue