This commit is contained in:
anatawa12 2026-06-24 19:50:14 +00:00 committed by GitHub
commit b0dbe6fa52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 855 additions and 387 deletions

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class DeletedNote1754137937997 {
name = 'DeletedNote1754137937997'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "deleted_note" ("id" character varying(32) NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE, "replyId" character varying(32), "renoteId" character varying(32), "userId" character varying(32) NOT NULL, "localOnly" boolean NOT NULL DEFAULT false, "uri" character varying(512), "url" character varying(512), "channelId" character varying(32), "replyUserId" character varying(32), "renoteUserId" character varying(32), CONSTRAINT "PK_1cb67148b7b707a03c63b2165fc" PRIMARY KEY ("id")); COMMENT ON COLUMN "deleted_note"."replyId" IS 'The ID of reply target.'; COMMENT ON COLUMN "deleted_note"."renoteId" IS 'The ID of renote target.'; COMMENT ON COLUMN "deleted_note"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "deleted_note"."uri" IS 'The URI of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."url" IS 'The human readable url of a note. it will be null when the note is local.'; COMMENT ON COLUMN "deleted_note"."channelId" IS 'The ID of source channel.'; COMMENT ON COLUMN "deleted_note"."replyUserId" IS '[Denormalized]'; COMMENT ON COLUMN "deleted_note"."renoteUserId" IS '[Denormalized]'`);
await queryRunner.query(`CREATE INDEX "IDX_12797cfa4c15d03d0dd649bc4b" ON "deleted_note" ("replyId") `);
await queryRunner.query(`CREATE INDEX "IDX_b6a4a8f31a98ddc5e07995c840" ON "deleted_note" ("renoteId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_95c76b088692f600b7a5352a4b" ON "deleted_note" ("uri") `);
await queryRunner.query(`CREATE INDEX "IDX_cd2da11aa690f8e854e68058ce" ON "deleted_note" ("channelId") `);
await queryRunner.query(`CREATE INDEX "IDX_31ca16e5929958668bf1d9a2d5" ON "deleted_note" ("userId", "id" DESC) `);
await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_cd2da11aa690f8e854e68058cef" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_cd2da11aa690f8e854e68058cef"`);
await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59"`);
await queryRunner.query(`DROP INDEX "public"."IDX_31ca16e5929958668bf1d9a2d5"`);
await queryRunner.query(`DROP INDEX "public"."IDX_cd2da11aa690f8e854e68058ce"`);
await queryRunner.query(`DROP INDEX "public"."IDX_95c76b088692f600b7a5352a4b"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b6a4a8f31a98ddc5e07995c840"`);
await queryRunner.query(`DROP INDEX "public"."IDX_12797cfa4c15d03d0dd649bc4b"`);
await queryRunner.query(`DROP TABLE "deleted_note"`);
}
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RemoveUserFromDeletedNote1767261423782 {
name = 'RemoveUserFromDeletedNote1767261423782'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "deleted_note" DROP CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59"`);
await queryRunner.query(`DROP INDEX "public"."IDX_31ca16e5929958668bf1d9a2d5"`);
await queryRunner.query(`ALTER TABLE "deleted_note" DROP COLUMN "userId"`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "deleted_note" ADD "userId" character varying(32) NOT NULL`);
await queryRunner.query(`CREATE INDEX "IDX_31ca16e5929958668bf1d9a2d5" ON "deleted_note" ("id", "userId") `);
await queryRunner.query(`ALTER TABLE "deleted_note" ADD CONSTRAINT "FK_d3b9dbab99de8644e4b0d5b7d59" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View file

@ -13,6 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import { MiDeletedNote } from '@/models/DeletedNote.js';
import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
@ -623,6 +624,7 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
const insert = new MiNote({
// Note: id は transaction内で確定させるので、ここの id は一時的なものである可能性がある
id: this.idService.gen(data.createdAt?.getTime()),
fileIds: data.files ? data.files.map(file => file.id) : [],
replyId: data.reply ? data.reply.id : null,
@ -681,11 +683,24 @@ export class NoteCreateService implements OnApplicationShutdown {
// 投稿を作成
try {
if (insert.hasPoll) {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.insert(MiNote, insert);
await this.db.transaction(async transactionalEntityManager => {
if (data.uri) {
// もし URI が指定されている場合は、MiDeletedNote から id を引き継ぐ。
const deletedOne = await transactionalEntityManager.findOneBy(MiDeletedNote, { uri: data.uri });
if (deletedOne != null) {
if (deletedOne.deletedAt) {
// もしこの投稿がモデレータ等によって削除されていた場合は、復活させてはいけないので、エラーにする
throw new Error('This note uri is deleted by moderator.');
}
// さもなければ、もとの id を引き継ぎ、MiDeletedNote からは削除する
insert.id = deletedOne.id;
await transactionalEntityManager.delete(MiDeletedNote, deletedOne.id);
}
}
await transactionalEntityManager.insert(MiNote, insert);
if (insert.hasPoll) {
const poll = new MiPoll({
noteId: insert.id,
choices: data.poll!.choices,
@ -699,10 +714,8 @@ export class NoteCreateService implements OnApplicationShutdown {
});
await transactionalEntityManager.insert(MiPoll, poll);
});
} else {
await this.notesRepository.insert(insert);
}
}
});
return {
...insert,

View file

@ -3,10 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Brackets, In, IsNull, Not } from 'typeorm';
import { Brackets, DataSource, In, IsNull, Not } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import { MiDeletedNote } from '@/models/DeletedNote.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -30,6 +32,9 @@ export class NoteDeleteService {
@Inject(DI.config)
private config: Config,
@Inject(DI.db)
private db: DataSource,
@Inject(DI.meta)
private meta: MiMeta,
@ -110,9 +115,28 @@ export class NoteDeleteService {
this.searchService.unindexNote(note);
await this.notesRepository.delete({
id: note.id,
userId: user.id,
await this.db.transaction(async transaction => {
await transaction.delete(MiNote, {
id: note.id,
userId: user.id,
});
// We keep some limited information about deleted renotes to preserve reply/renote chains
// We do not keep for pure renotes because it will not have any replies/renotes.
// (Historically we can renote pure renotes and can reply to pure renotes with API, but it was just a bug and fixed.)
if (!(isRenote(note) && !isQuote(note))) {
await transaction.save(MiDeletedNote, {
id: note.id,
deletedAt: new Date(),
replyId: note.replyId,
renoteId: note.renoteId,
localOnly: note.localOnly,
uri: note.uri,
url: note.url,
channelId: note.channelId,
replyUserId: note.replyUserId,
renoteUserId: note.renoteUserId,
});
}
});
if (deleter && (note.userId !== deleter.id)) {

View file

@ -20,7 +20,11 @@ import { generateNativeUserToken } from '@/misc/token.js';
import { IdService } from '@/core/IdService.js';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const;
export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy', 'ghost'] as const;
export const systemAccountName: Partial<Record<typeof SYSTEM_ACCOUNT_TYPES[number], string>> = {
ghost: 'Unknown User',
};
@Injectable()
export class SystemAccountService implements OnApplicationShutdown {
@ -98,7 +102,7 @@ export class SystemAccountService implements OnApplicationShutdown {
} else {
const created = await this.createCorrespondingUser(type, {
username: `system.${type}`, // NOTE: (できれば避けたいが) . が含まれるかどうかでシステムアカウントかどうかを判定している処理もあるので変えないように
name: this.meta.name,
name: systemAccountName[type] ?? this.meta.name,
});
this.cache.set(type, created);
return created;

View file

@ -11,7 +11,7 @@ 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 } from '@/models/_.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, DeletedNotesRepository, MiDeletedNote } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
@ -23,6 +23,7 @@ import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
// is-renote.tsとよしなにリンク
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
@ -59,6 +60,18 @@ async function nullIfEntityNotFound<T>(promise: Promise<T>): Promise<T | null> {
}
}
type PackSingleOptions = {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
};
};
@Injectable()
export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@ -82,6 +95,9 @@ export class NoteEntityService implements OnModuleInit {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.deletedNotesRepository)
private deletedNotesRepository: DeletedNotesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@ -97,6 +113,7 @@ export class NoteEntityService implements OnModuleInit {
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private systemAccountService: SystemAccountService,
//private userEntityService: UserEntityService,
//private driveFileEntityService: DriveFileEntityService,
//private customEmojiService: CustomEmojiService,
@ -339,21 +356,24 @@ export class NoteEntityService implements OnModuleInit {
return fileIds.map(id => packedFiles.get(id)).filter(x => x != null);
}
@bindThis
public async packMayDeleted(
src: MiNote | MiDeletedNote,
me?: { id: MiUser['id'] } | null | undefined,
options?: PackSingleOptions,
): Promise<Packed<'Note'>> {
if ('deletedAt' in src) {
return this.packDeletedNote(src, me, options);
} else {
return this.pack(src, me, options);
}
}
@bindThis
public async pack(
src: MiNote['id'] | MiNote,
me?: { id: MiUser['id'] } | null | undefined,
options?: {
detail?: boolean;
skipHide?: boolean;
withReactionAndUserPairCache?: boolean;
_hint_?: {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
};
},
options?: PackSingleOptions,
): Promise<Packed<'Note'>> {
const opts = Object.assign({
detail: true,
@ -362,7 +382,19 @@ export class NoteEntityService implements OnModuleInit {
}, options);
const meId = me ? me.id : null;
const note = typeof src === 'object' ? src : await this.noteLoader.load(src);
let note: MiNote;
if (typeof src === 'object') {
note = src;
} else {
try {
note = await this.noteLoader.load(src);
} catch (err) {
if (err instanceof EntityNotFoundError) {
return this.packDeletedNote(src);
}
throw err;
}
}
const host = note.userHost;
const bufferedReactions = opts._hint_?.bufferedReactions != null
@ -432,16 +464,14 @@ export class NoteEntityService implements OnModuleInit {
...(opts.detail ? {
clippedCount: note.clippedCount,
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
reply: (note.replyId && note.reply === null) ? null : note.replyId ? nullIfEntityNotFound(this.pack(note.reply ?? note.replyId, me, {
reply: note.replyId ? nullIfEntityNotFound(this.pack(note.reply ?? note.replyId, me, {
detail: false,
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
})) : undefined,
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
renote: (note.renoteId && note.renote === null) ? null : note.renoteId ? nullIfEntityNotFound(this.pack(note.renote ?? note.renoteId, me, {
renote: note.renoteId ? nullIfEntityNotFound(this.pack(note.renote ?? note.renoteId, me, {
detail: true,
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
@ -469,9 +499,97 @@ export class NoteEntityService implements OnModuleInit {
return packed;
}
public async packDeletedNote(
srcId: MiNote['id'] | MiDeletedNote,
me?: { id: MiUser['id'] } | null | undefined,
options?: PackSingleOptions,
): Promise<Packed<'Note'>> {
const opts = Object.assign({
detail: true,
skipHide: false,
withReactionAndUserPairCache: false,
}, options);
const deletedNote = typeof srcId === 'object' ? srcId : await this.deletedNotesRepository.findOneOrFail({
where: {
id: srcId,
},
relations: {
renote: true,
reply: true,
channel: true,
},
});
const channel = deletedNote.channelId
? deletedNote.channel ?? await this.channelsRepository.findOneBy({ id: deletedNote.channelId })
: null;
const ghostUser = await this.systemAccountService.fetch('ghost');
return await awaitAll({
id: deletedNote.id,
createdAt: this.idService.parse(deletedNote.id).date.toISOString(),
deletedAt: deletedNote.deletedAt?.toISOString() ?? undefined,
userId: ghostUser.id,
user: this.userEntityService.pack(ghostUser, me),
text: deletedNote.deletedAt ? "<small>Deleted note</small>" : "<small>Forgotten remote note. View on remote instance to see contents.</small>",
cw: null,
visibility: 'public',
localOnly: deletedNote.localOnly,
reactionAcceptance: 'likeOnly',
visibleUserIds: undefined,
renoteCount: 0,
repliesCount: 0,
reactionCount: 0,
reactions: {},
reactionEmojis: {},
reactionAndUserPairCache: [],
emojis: {},
tags: undefined,
fileIds: [],
files: [],
replyId: deletedNote.replyId,
renoteId: deletedNote.renoteId,
channelId: deletedNote.channelId ?? undefined,
channel: channel ? {
id: channel.id,
name: channel.name,
color: channel.color,
isSensitive: channel.isSensitive,
allowRenoteToExternal: channel.allowRenoteToExternal,
userId: channel.userId,
} : undefined,
mentions: undefined,
hasPoll: undefined,
uri: deletedNote.uri ?? undefined,
url: deletedNote.url ?? undefined,
...(opts.detail ? {
clippedCount: 0,
reply: deletedNote.replyId ? this.pack(deletedNote.reply ?? deletedNote.replyId, me, {
detail: false,
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
}) : undefined,
renote: deletedNote.renoteId ? this.pack(deletedNote.renote ?? deletedNote.renoteId, me, {
detail: true,
skipHide: opts.skipHide,
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
_hint_: options?._hint_,
}) : undefined,
poll: undefined,
} : {}),
});
}
@bindThis
public async packMany(
notes: MiNote[],
notes: (MiNote | MiDeletedNote)[],
me?: { id: MiUser['id'] } | null | undefined,
options?: {
detail?: boolean;
@ -480,7 +598,9 @@ export class NoteEntityService implements OnModuleInit {
) {
if (notes.length === 0) return [];
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
const liveNotes = notes.filter((n): n is MiNote => !('deletedAt' in n));
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(liveNotes)]) : null;
const meId = me ? me.id : null;
const myReactionsMap = new Map<MiNote['id'], string | null>();
@ -490,7 +610,7 @@ export class NoteEntityService implements OnModuleInit {
// パフォーマンスのためートが作成されてから2秒以上経っていない場合はリアクションを取得しない
const oldId = this.idService.gen(Date.now() - 2000);
for (const note of notes) {
for (const note of liveNotes) {
if (isPureRenote(note)) {
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
@ -538,19 +658,19 @@ export class NoteEntityService implements OnModuleInit {
}
}
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(liveNotes));
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
const fileIds = liveNotes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
const users = [
...notes.map(({ user, userId }) => user ?? userId),
...liveNotes.map(({ user, userId }) => user ?? userId),
...notes.map(({ replyUserId }) => replyUserId).filter(x => x != null),
...notes.map(({ renoteUserId }) => renoteUserId).filter(x => x != null),
];
const packedUsers = await this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(notes.map(n => this.pack(n, me, {
return await Promise.all(notes.map(n => this.packMayDeleted(n, me, {
...options,
_hint_: {
bufferedReactions,

View file

@ -91,5 +91,6 @@ export const DI = {
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
noteDraftsRepository: Symbol('noteDraftsRepository'),
deletedNotesRepository: Symbol('deletedNotesRepository'),
//#endregion
};

View file

@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { MiChannel } from '@/models/Channel.js';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiNote } from './Note.js';
/**
* This model represents a deleted note in the Misskey system.
* Deleted note can be one of:
* - A note that was deleted by the user.
* - A remote note that is old and has been removed from the
* In both cases, we want to keep little information about the note to allow users to
* - see the reply / renote relationships of the note (if the note is in the reply / renote chain)
* - see the original url of the note if the note was remote
*/
@Entity('deleted_note')
export class MiDeletedNote {
// The id of the note must be same as the original note's id.
@PrimaryColumn(id())
public id: string;
// For remote notes that are deleted locally but not deleted on the remote server, this will be null.
// For actually deleted notes, this will be the time when the note was deleted.
@Column('timestamp with time zone', {
nullable: true,
})
public deletedAt: Date | null;
@Index()
@Column({
...id(),
nullable: true,
comment: 'The ID of reply target.',
})
public replyId: MiNote['id'] | null;
@ManyToOne(type => MiNote, {
createForeignKeyConstraints: false,
})
@JoinColumn()
public reply: MiNote | null;
@Index()
@Column({
...id(),
nullable: true,
comment: 'The ID of renote target.',
})
public renoteId: MiNote['id'] | null;
@ManyToOne(type => MiNote, {
createForeignKeyConstraints: false,
})
@JoinColumn()
public renote: MiNote | null;
@Column('boolean', {
default: false,
})
public localOnly: boolean;
@Index({ unique: true })
@Column('varchar', {
length: 512, nullable: true,
comment: 'The URI of a note. it will be null when the note is local.',
})
public uri: string | null;
@Column('varchar', {
length: 512, nullable: true,
comment: 'The human readable url of a note. it will be null when the note is local.',
})
public url: string | null;
@Index() // for cascading
@Column({
...id(),
nullable: true,
comment: 'The ID of source channel.',
})
public channelId: MiChannel['id'] | null;
@Column({
...id(),
nullable: true,
comment: '[Denormalized]',
})
public replyUserId: MiUser['id'] | null;
@Column({
...id(),
nullable: true,
comment: '[Denormalized]',
})
public renoteUserId: MiUser['id'] | null;
@ManyToOne(type => MiChannel, {
onDelete: 'CASCADE',
})
@JoinColumn()
public channel: MiChannel | null;
constructor(data: Partial<MiDeletedNote>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}

View file

@ -44,6 +44,7 @@ import {
MiNoteReaction,
MiNoteThreadMuting,
MiNoteDraft,
MiDeletedNote,
MiPage,
MiPageLike,
MiPasswordResetRequest,
@ -148,6 +149,12 @@ const $noteDraftsRepository: Provider = {
inject: [DI.db],
};
const $deletedNotesRepository: Provider = {
provide: DI.deletedNotesRepository,
useFactory: (db: DataSource) => db.getRepository(MiDeletedNote).extend(miRepository as MiRepository<MiDeletedNote>),
inject: [DI.db],
};
const $pollsRepository: Provider = {
provide: DI.pollsRepository,
useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository<MiPoll>),
@ -557,6 +564,7 @@ const $reversiGamesRepository: Provider = {
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteDraftsRepository,
$deletedNotesRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
@ -635,6 +643,7 @@ const $reversiGamesRepository: Provider = {
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteDraftsRepository,
$deletedNotesRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,

View file

@ -32,6 +32,7 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiClip } from '@/models/Clip.js';
import { MiClipFavorite } from '@/models/ClipFavorite.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiDeletedNote } from '@/models/DeletedNote.js';
import { MiDriveFile } from '@/models/DriveFile.js';
import { MiDriveFolder } from '@/models/DriveFolder.js';
import { MiEmoji } from '@/models/Emoji.js';
@ -114,6 +115,7 @@ export {
MiClip,
MiClipNote,
MiClipFavorite,
MiDeletedNote,
MiDriveFile,
MiDriveFolder,
MiEmoji,
@ -194,6 +196,7 @@ export type ChannelMutingRepository = Repository<MiChannelMuting> & MiRepository
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
export type DeletedNotesRepository = Repository<MiDeletedNote> & MiRepository<MiDeletedNote>;
export type DriveFilesRepository = Repository<MiDriveFile> & MiRepository<MiDriveFile>;
export type DriveFoldersRepository = Repository<MiDriveFolder> & MiRepository<MiDriveFolder>;
export type EmojisRepository = Repository<MiEmoji> & MiRepository<MiEmoji>;

View file

@ -46,6 +46,7 @@ import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
import { MiNoteDraft } from '@/models/NoteDraft.js';
import { MiDeletedNote } from '@/models/DeletedNote.js';
import { MiPage } from '@/models/Page.js';
import { MiPageLike } from '@/models/PageLike.js';
import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
@ -206,6 +207,7 @@ export const entities = [
MiNoteReaction,
MiNoteThreadMuting,
MiNoteDraft,
MiDeletedNote,
MiPage,
MiPageLike,
MiGalleryPost,

View file

@ -4,17 +4,19 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { DataSource, MoreThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import { MiDeletedNote } from '@/models/DeletedNote.js';
import { EmailService } from '@/core/EmailService.js';
import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js';
import { PageService } from '@/core/PageService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js';
@ -24,6 +26,9 @@ export class DeleteAccountProcessorService {
private logger: Logger;
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -78,7 +83,24 @@ export class DeleteAccountProcessorService {
cursor = notes.at(-1)?.id ?? null;
await this.notesRepository.delete(notes.map(note => note.id));
await this.db.transaction(async transaction => {
await transaction.delete(MiNote, notes.map(note => note.id));
// We keep some limited information about deleted renotes to preserve reply/renote chains
// We do not keep for pure renotes because it will not have any replies/renotes.
// (Historically we can renote pure renotes and can reply to pure renotes with API, but it was just a bug and fixed.)
await transaction.save(MiDeletedNote, notes.filter(note => !(isRenote(note) && !isQuote(note))).map(note => ({
id: note.id,
deletedAt: new Date(),
replyId: note.replyId,
renoteId: note.renoteId,
localOnly: note.localOnly,
uri: note.uri,
url: note.url,
channelId: note.channelId,
replyUserId: note.replyUserId,
renoteUserId: note.renoteUserId,
})));
});
for (const note of notes) {
await this.searchService.unindexNote(note);

View file

@ -5,10 +5,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UsersRepository } from '@/models/_.js';
import type { DeletedNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import type { MiDeletedNote } from '@/models/DeletedNote.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@ -21,6 +22,9 @@ export class GetterService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.deletedNotesRepository)
private deletedNotesRepository: DeletedNotesRepository,
private userEntityService: UserEntityService,
) {
}
@ -40,8 +44,28 @@ export class GetterService {
}
@bindThis
public async getNoteWithRelations(noteId: MiNote['id']) {
const note = await this.notesRepository.findOne({
public async getMayDeletedNoteOrNull(noteId: MiNote['id']): Promise<MiNote | MiDeletedNote | null> {
let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOneBy({ id: noteId });
note ??= await this.deletedNotesRepository.findOneBy({ id: noteId });
return note;
}
@bindThis
public async getMayDeletedNote(noteId: MiNote['id']): Promise<(MiNote | MiDeletedNote)> {
const note = await this.getMayDeletedNoteOrNull(noteId);
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
}
return note;
}
@bindThis
public async getMayDeletedNoteWithRelations(noteId: MiNote['id']): Promise<(MiNote & { user: NonNullable<MiNote['user']> }) | MiDeletedNote> {
let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOne({
where: { id: noteId },
relations: {
user: true,
@ -54,11 +78,23 @@ export class GetterService {
},
});
note ??= await this.deletedNotesRepository.findOne({
where: { id: noteId },
relations: {
reply: {
user: true,
},
renote: {
user: true,
},
},
});
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
}
return note;
return note as (MiNote | MiDeletedNote) & { user: object };
}
/**

View file

@ -3,12 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { MiNote } from '@/models/Note.js';
import type { NotesRepository } from '@/models/_.js';
import type { MiDeletedNote } from '@/models/DeletedNote.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
@ -49,24 +48,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
const note = await this.getterService.getMayDeletedNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
const conversation: MiNote[] = [];
const conversation: (MiNote | MiDeletedNote)[] = [];
let i = 0;
const get = async (id: any) => {
const get = async (id: MiNote['id']) => {
i++;
const p = await this.notesRepository.findOneBy({ id });
const p = await this.getterService.getMayDeletedNoteOrNull(id);
if (p == null) return;
if (i > ps.offset!) {

View file

@ -9,6 +9,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { MiNote } from '@/models/Note.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -61,12 +63,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNoteWithRelations(ps.noteId).catch(err => {
const note = await this.getterService.getMayDeletedNoteWithRelations(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
if (note.user!.requireSigninToViewContents && me == null) {
if ('user' in note && note.user.requireSigninToViewContents && me == null) {
throw new ApiError(meta.errors.contentRestrictedByUser);
}
@ -74,11 +76,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.contentRestrictedByServer);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && 'user' in note && note.user.host != null && me == null) {
throw new ApiError(meta.errors.contentRestrictedByServer);
}
return await this.noteEntityService.pack(note, me, {
return await this.noteEntityService.packMayDeleted(note, me, {
detail: true,
});
});

View file

@ -1,5 +1,5 @@
import { describe, test, beforeAll, afterAll } from 'vitest';
import assert, { rejects, strictEqual } from 'node:assert';
import assert, { notEqual, rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
@ -155,13 +155,8 @@ describe('Note', () => {
await bob.client.request('notes/delete', { noteId: note.id });
await sleep();
await rejects(
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
const noteInARemoved = await carol.client.request('notes/show', { noteId: noteInA.id });
notEqual(noteInARemoved.deletedAt, null);
});
afterAll(async () => {
@ -180,13 +175,8 @@ describe('Note', () => {
await bob.client.request('notes/delete', { noteId: note.id });
await sleep();
await rejects(
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
const noteInARemoved = await alice.client.request('notes/show', { noteId: noteInA.id });
notEqual(noteInARemoved.deletedAt, null);
});
});
@ -200,13 +190,8 @@ describe('Note', () => {
await bob.client.request('notes/delete', { noteId: note.id });
await sleep();
await rejects(
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
const noteInARemoved = await alice.client.request('notes/show', { noteId: noteInA.id });
notEqual(noteInARemoved.deletedAt, null);
});
});
@ -242,13 +227,9 @@ describe('Note', () => {
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
const bMod = await createModerator('b.test');
await bMod.client.request('notes/delete', { noteId: noteInB.id });
await rejects(
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
(err: any) => {
strictEqual(err.code, 'NO_SUCH_NOTE');
return true;
},
);
const noteInBRemoved = await bob.client.request('notes/show', { noteId: noteInB.id });
notEqual(noteInBRemoved.deletedAt, null);
});
/**

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root]"
:tabindex="isDeleted ? '-1' : '0'"
>
<EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
<EmNoteSub v-if="appearNote.replyId" :note="appearNote.reply ?? null" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
<!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<span v-if="appearNote.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<EmMfm
v-if="appearNote.text"
:parsedNodes="parsed"
@ -69,7 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
</div>
<EmPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
<div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<div v-if="appearNote.renoteId != null" :class="$style.quote"><EmNoteSimple :note="appearNote.renote ?? null" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
</button>
@ -149,7 +150,7 @@ const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const appearNote = computed(() => getAppearNote(note.value));
const appearNote = computed(() => getAppearNote(note.value) ?? note.value);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const isLong = shouldCollapsed(appearNote.value, []);

View file

@ -36,8 +36,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<article :class="$style.note">
<header :class="$style.noteHeader">
<EmAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link/>
<div :class="$style.noteHeaderBody">
<div v-if="appearNote.deletedAt" :class="$style.noteHeaderAvatar"></div>
<EmAvatar v-else :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link/>
<div v-if="appearNote.deletedAt" :class="$style.noteHeaderBody">
<div :class="$style.noteHeaderName" style="opacity: 0.5;">
Unknown User
</div>
</div>
<div v-else :class="$style.noteHeaderBody">
<div :class="$style.noteHeaderBodyUpper">
<div style="min-width: 0;">
<div class="_nowrap">
@ -65,20 +71,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<div v-if="appearNote.deletedAt" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<EmMfm
v-if="appearNote.text"
v-else-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'respect'"
:emojiUrls="appearNote.emojis"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<a v-if="appearNote.renoteId != null" :class="$style.rn">RN:</a>
<div v-if="appearNote.files && appearNote.files.length > 0">
<EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
</div>
<EmPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
<div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<div v-if="appearNote.renoteId" :class="$style.quote"><EmNoteSimple :note="appearNote.renote ?? null" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
</button>

View file

@ -5,15 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<header :class="$style.root">
<EmA :class="$style.name" :to="userPage(note.user)">
<EmUserName :user="note.user"/>
</EmA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><EmAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
<div :class="$style.info">
<template v-if="note && !note.deletedAt">
<EmA :class="$style.name" :to="userPage(note.user)">
<EmUserName :user="note.user"/>
</EmA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><EmAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
</template>
<template v-else>
<div :class="$style.name" style="opacity: 0.5;">
Unknown User
</div>
</template>
<div v-if="note" :class="$style.info">
<EmA :to="notePage(note)">
<EmTime :time="note.createdAt" colored/>
</EmA>
@ -39,7 +46,7 @@ import EmAcct from '@/components/EmAcct.vue';
import EmTime from '@/components/EmTime.vue';
defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note | null;
}>();
</script>

View file

@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<EmAvatar :class="$style.avatar" :user="note.user" link preview/>
<EmAvatar v-if="note != null && !note.deletedAt" :class="$style.avatar" :user="note.user" link preview/>
<div v-else :class="$style.avatar"></div>
<div :class="$style.main">
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<p v-if="note?.cw != null" :class="$style.cw">
<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
</p>
<div v-show="note.cw == null || showContent">
<div v-show="note?.cw == null || showContent">
<EmSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@ -31,7 +32,7 @@ import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
import EmMfm from '@/components/EmMfm.js';
const props = defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note | null;
}>();
const showContent = ref(false);

View file

@ -6,16 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :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>
<EmAvatar :class="$style.avatar" :user="note.user" link preview/>
<div v-if="note?.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
<EmAvatar v-if="note && !note.deletedAt" :class="$style.avatar" :user="note.user" link preview/>
<div v-else :class="$style.avatar"></div>
<div :class="$style.body">
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<p v-if="note?.cw != null" :class="$style.cw">
<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
</p>
<div v-show="note.cw == null || showContent">
<div v-show="note?.cw == null || showContent">
<EmSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@ -43,7 +44,7 @@ import { i18n } from '@/i18n.js';
import EmMfm from '@/components/EmMfm.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note | null;
detail?: boolean;
// how many notes are in between this one and the note being viewed in detail

View file

@ -6,17 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
<div>
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<EmA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<EmMfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<EmA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</EmA>
<span v-if="note?.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="note?.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<span v-if="note == null || note?.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<EmMfm v-else-if="note?.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<EmA v-if="note?.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</EmA>
</div>
<details v-if="note.files && note.files.length > 0">
<details v-if="note?.files && note.files.length > 0">
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<EmMediaList :mediaList="note.files" :originalEntityUrl="`${url}/notes/${note.id}`"/>
</details>
<details v-if="note.poll">
<details v-if="note?.poll">
<summary>{{ i18n.ts.poll }}</summary>
<EmPoll :noteId="note.id" :poll="note.poll"/>
</details>
@ -41,10 +41,10 @@ import EmA from '@/components/EmA.vue';
import EmMfm from '@/components/EmMfm.js';
const props = defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note | null;
}>();
const isLong = shouldCollapsed(props.note, []);
const isLong = props.note && shouldCollapsed(props.note, []);
const collapsed = ref(isLong);
</script>

View file

@ -18,7 +18,7 @@ import MkButton from '@/components/MkButton.vue';
const props = defineProps<{
modelValue: boolean;
text: string | null;
renote?: Misskey.entities.Note | null;
isRenote?: boolean;
files?: Misskey.entities.DriveFile[];
poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null;
}>();
@ -30,7 +30,7 @@ const emit = defineEmits<{
const label = computed(() => {
return concat([
props.text ? [i18n.tsx._cw.chars({ count: props.text.length })] : [],
props.renote ? [i18n.ts.quote] : [],
props.isRenote ? [i18n.ts.quote] : [],
props.files && props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [],
props.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / ');

View file

@ -38,19 +38,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
</div>
</div>
<div v-if="isRenote && note.renote == null" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<div v-else-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<MkNoteUserAvatar :class="$style.collapsedRenoteTargetAvatar" :note="appearNote" link preview/>
<Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
<MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="appearNote.user" :link="!mock" :preview="!mock"/>
<MkNoteUserAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :note="appearNote" :link="!mock" :preview="!mock"/>
<div :class="$style.main">
<MkNoteHeader :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<MkInstanceTicker v-if="!appearNote.deletedAt && showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
@ -67,8 +64,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<span v-if="appearNote.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<Mfm
v-if="appearNote.text"
v-else-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
@ -113,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
v-if="!appearNote.deletedAt && appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
@ -127,12 +125,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkReactionsViewer>
<footer :class="$style.footer">
<button :class="$style.footerButton" class="_button" @click="reply()">
<button v-if="!appearNote.deletedAt" :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button
v-if="canRenote"
v-if="canRenote && !appearNote.deletedAt"
ref="renoteButton"
:class="$style.footerButton"
class="_button"
@ -144,16 +145,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<button v-if="!appearNote.deletedAt" ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<template v-if="prefer.s.showClipButtonInNoteFooter">
<button v-if="!appearNote.deletedAt" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button v-else :class="$style.noteFooterButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
</template>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
@ -164,23 +173,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="!hardMuted && !hideByPlugin" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkNoteUserName :note="appearNote" link/>
</template>
</I18n>
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkNoteUserName :note="appearNote" link/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkNoteUserName :note="appearNote"/>
</template>
<template #word>
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
@ -209,6 +212,8 @@ import type { Keymap } from '@/utility/hotkey.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNoteUserAvatar from '@/components/MkNoteUserAvatar.vue';
import MkNoteUserName from '@/components/MkNoteUserName.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';

View file

@ -43,196 +43,203 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
<div v-if="isRenote && note.renote == null" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<template v-else>
<article :class="$style.note" @contextmenu.stop="onContextmenu">
<header :class="$style.noteHeader">
<MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
<div :class="$style.noteHeaderBody">
<div>
<MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
<MkUserName :nowrap="false" :user="appearNote.user"/>
</MkA>
<span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
<div :class="$style.noteHeaderInfo">
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
<article :class="$style.note" @contextmenu.stop="onContextmenu">
<header :class="$style.noteHeader">
<MkNoteUserAvatar :class="$style.noteHeaderAvatar" :note="appearNote" indicator link preview/>
<div :class="$style.noteHeaderBody">
<div>
<MkNoteUserName :class="$style.noteHeaderName" :note="appearNote" :nowrap="false"/>
<span v-if="!appearNote.deletedAt && appearNote.user.isBot" :class="$style.isBot">bot</span>
<div v-if="!appearNote.deletedAt" :class="$style.noteHeaderInfo">
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
<div :class="$style.noteHeaderUsernameAndBadgeRoles">
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'respect'"
:emojiUrls="appearNote.emojis"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
class="_selectable"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
<div v-if="!appearNote.deletedAt" :class="$style.noteHeaderUsernameAndBadgeRoles">
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
<MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</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="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
<MkInstanceTicker v-if="!appearNote.deletedAt && showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
<footer>
<div :class="$style.noteFooterInfo">
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
<span style="margin-left: 0.5em;">
<span style="border: 1px solid var(--MI_THEME-divider); margin-right: 0.5em;"></span>
<i v-if="appearNote.visibility === 'public'" class="ti ti-world"></i>
<i v-else-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
<span style="margin-left: 0.3em;">{{ i18n.ts._visibility[appearNote.visibility] }}</span>
</span>
</div>
<MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button
v-if="canRenote"
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<MkCwButton v-model="showContent" :text="appearNote.text" :isRenote="appearNote.renoteId != null" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<div v-if="appearNote.deletedAt" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<Mfm
v-else-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'respect'"
:emojiUrls="appearNote.emojis"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
class="_selectable"
/>
<a v-if="appearNote.renoteId != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renoteId" :class="$style.quote"><MkNoteSimple :note="appearNote.renote ?? null" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer>
<div :class="$style.noteFooterInfo">
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
<span style="margin-left: 0.5em;">
<span style="border: 1px solid var(--MI_THEME-divider); margin-right: 0.5em;"></span>
<i v-if="appearNote.visibility === 'public'" class="ti ti-world"></i>
<i v-else-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
<span style="margin-left: 0.3em;">{{ i18n.ts._visibility[appearNote.visibility] }}</span>
</span>
</div>
<MkReactionsViewer
v-if="!appearNote.deletedAt && appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
/>
<button v-if="!appearNote.deletedAt" class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button v-else :class="$style.noteFooterButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button
v-if="canRenote && !appearNote.deletedAt"
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="!appearNote.deletedAt" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-else :class="$style.noteFooterButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<template v-if="prefer.s.showClipButtonInNoteFooter">
<button v-if="!appearNote.deletedAt" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
<button v-else :class="$style.noteFooterButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
</footer>
</article>
<div :class="$style.tabs">
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ti ti-arrow-back-up"></i> {{ i18n.ts.replies }}</button>
</template>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
</article>
<div :class="$style.tabs">
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ti ti-arrow-back-up"></i> {{ i18n.ts.replies }}</button>
<template v-if="!appearNote.deletedAt">
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ti ti-repeat"></i> {{ i18n.ts.renotes }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ti ti-icons"></i> {{ i18n.ts.reactions }}</button>
</template>
</div>
<div>
<div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
</div>
<div>
<div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :paginator="renotesPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
</button>
</div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :paginator="reactionsPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :paginator="renotesPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
</template>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
</button>
</div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :paginator="reactionsPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
</div>
</div>
<div v-else-if="muted" class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkNoteUserName :note="appearNote"/>
</template>
</I18n>
</div>
@ -257,6 +264,8 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkNoteUserAvatar from '@/components/MkNoteUserAvatar.vue';
import MkNoteUserName from '@/components/MkNoteUserName.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { userPage } from '@/filters/user.js';

View file

@ -5,18 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<header :class="$style.root">
<div v-if="mock" :class="$style.name">
<MkUserName :user="note.user"/>
</div>
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
<div :class="$style.info">
<template v-if="note && !note.deletedAt">
<MkNoteUserName :class="$style.name" :note="note" :link="!mock"/>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
</template>
<template v-else>
<MkNoteUserName :class="$style.name" :note="note"/>
</template>
<div v-if="note" :class="$style.info">
<div v-if="mock">
<MkTime :time="note.createdAt" colored/>
</div>
@ -39,11 +39,11 @@ import { inject } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import MkNoteUserName from '@/components/MkNoteUserName.vue';
import { DI } from '@/di.js';
defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note | null;
}>();
const mock = inject(DI.mock, false);

View file

@ -4,36 +4,33 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="note" :class="$style.root">
<MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="note.user" link preview/>
<div :class="$style.root">
<MkNoteUserAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :note="note" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<p v-if="note?.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
<div v-show="note?.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
</div>
</div>
<div v-else :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteUserAvatar from '@/components/MkNoteUserAvatar.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
defineProps<{
note: Misskey.entities.Note | null;
}>();
@ -109,14 +106,4 @@ const showContent = ref(false);
height: 48px;
}
}
.deleted {
text-align: center;
padding: 8px !important;
margin: 8px 8px 0 8px;
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
border-radius: 8px;
}
</style>

View file

@ -4,21 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="note == null" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<div v-else-if="!muted" :class="[$style.root, { [$style.children]: depth > 1 }]">
<div v-if="!muted" :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/>
<div v-if="note?.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
<MkNoteUserAvatar :class="$style.avatar" :note="note" link preview/>
<div :class="$style.body">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<p v-if="note?.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
<div v-show="note?.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@ -27,16 +24,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="depth < 5">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1"/>
</template>
<div v-else :class="$style.more">
<div v-else-if="note" :class="$style.more">
<MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA>
</div>
</div>
<div v-else :class="$style.muted" @click="muted = false">
<!-- note must not be null if muted is false, but necessary for type checking. -->
<div v-else-if="note" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<MkNoteUserName :note="note" link/>
</template>
</I18n>
</div>
@ -46,13 +42,14 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteUserAvatar from '@/components/MkNoteUserAvatar.vue';
import MkNoteUserName from '@/components/MkNoteUserName.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { notePage } from '@/filters/note.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
const props = withDefaults(defineProps<{
@ -163,14 +160,4 @@ if (props.detail && props.note) {
margin: 8px 8px 0 8px;
border-radius: 8px;
}
.deleted {
text-align: center;
padding: 8px !important;
margin: 8px 8px 0 8px;
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
border-radius: 8px;
}
</style>

View file

@ -0,0 +1,24 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="note == null || note.deletedAt"></div>
<MkAvatar v-else :user="note.user" :indicator="indicator" :link="link" :preview="preview"/>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
withDefaults(defineProps<{
note: Misskey.entities.Note | null;
indicator?: boolean;
link?: boolean;
preview?: boolean;
}>(), {
indicator: false,
link: false,
preview: false,
});
</script>

View file

@ -0,0 +1,30 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<span v-if="note == null || note.deletedAt" style="opacity: 0.5;">
Unknown User
</span>
<MkA v-else-if="link" v-user-preview="note.userId" :to="userPage(note.user)">
<MkUserName :user="note.user" :nowrap="nowrap"/>
</MkA>
<span v-else>
<MkUserName :user="note.user" :nowrap="nowrap"/>
</span>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { userPage } from '@/filters/user.js';
withDefaults(defineProps<{
note: Misskey.entities.Note | null;
link?: boolean;
nowrap?: boolean;
}>(), {
link: true,
nowrap: true,
});
</script>

View file

@ -6,17 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
<div>
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
<span v-if="note?.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="note?.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<span v-if="note == null || note?.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<Mfm v-else-if="note?.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkA v-if="note?.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files && note.files.length > 0">
<details v-if="note?.files && note.files.length > 0">
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
<MkMediaList :mediaList="note.files"/>
</details>
<details v-if="note.poll">
<details v-if="note?.poll">
<summary>{{ i18n.ts.poll }}</summary>
<MkPoll
:noteId="note.id"
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:emojiUrls="note.emojis"
/>
</details>
<MkA v-if="note.hasPoll && note.poll == null" :to="`/notes/${note.id}`">({{ i18n.ts.poll }})</MkA>
<MkA v-if="note?.hasPoll && note.poll == null" :to="`/notes/${note.id}`">({{ i18n.ts.poll }})</MkA>
<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
</button>
@ -46,10 +46,10 @@ import MkPoll from '@/components/MkPoll.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note | null;
}>();
const isLong = shouldCollapsed(props.note, []);
const isLong = props.note && shouldCollapsed(props.note, []);
const collapsed = ref(isLong);
</script>

View file

@ -349,7 +349,29 @@ export function getNoteMenu(props: {
const menuItems: MenuItem[] = [];
if ($i) {
if (appearNote.deletedAt) {
menuItems.push({
icon: 'ti ti-info-circle',
text: i18n.ts.details,
action: openDetail,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
if (link != null) {
menuItems.push({
icon: 'ti ti-link',
text: i18n.ts.copyRemoteLink,
action: () => {
copyToClipboard(link);
},
}, {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(link, '_blank', 'noopener');
},
});
}
} else if ($i) {
const statePromise = misskeyApi('notes/state', {
noteId: appearNote.id,
});