Merge af5a70732f
into d4654dd7bd
This commit is contained in:
commit
b92226d5db
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
||||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||||
import { MiNote } from '@/models/Note.js';
|
import { MiNote } from '@/models/Note.js';
|
||||||
|
import { MiDeletedNote } from '@/models/DeletedNote.js';
|
||||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
import type { MiApp } from '@/models/App.js';
|
import type { MiApp } from '@/models/App.js';
|
||||||
|
@ -403,6 +404,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
@bindThis
|
@bindThis
|
||||||
private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
|
private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
|
||||||
const insert = new MiNote({
|
const insert = new MiNote({
|
||||||
|
// Note: id は transaction内で確定させるので、ここの id は一時的なものである可能性がある
|
||||||
id: this.idService.gen(data.createdAt?.getTime()),
|
id: this.idService.gen(data.createdAt?.getTime()),
|
||||||
fileIds: data.files ? data.files.map(file => file.id) : [],
|
fileIds: data.files ? data.files.map(file => file.id) : [],
|
||||||
replyId: data.reply ? data.reply.id : null,
|
replyId: data.reply ? data.reply.id : null,
|
||||||
|
@ -460,11 +462,24 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// 投稿を作成
|
// 投稿を作成
|
||||||
try {
|
try {
|
||||||
if (insert.hasPoll) {
|
await this.db.transaction(async transactionalEntityManager => {
|
||||||
// Start transaction
|
if (data.uri) {
|
||||||
await this.db.transaction(async transactionalEntityManager => {
|
// もし URI が指定されている場合は、MiDeletedNote から id を引き継ぐ。
|
||||||
await transactionalEntityManager.insert(MiNote, insert);
|
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({
|
const poll = new MiPoll({
|
||||||
noteId: insert.id,
|
noteId: insert.id,
|
||||||
choices: data.poll!.choices,
|
choices: data.poll!.choices,
|
||||||
|
@ -478,10 +493,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
|
|
||||||
await transactionalEntityManager.insert(MiPoll, poll);
|
await transactionalEntityManager.insert(MiPoll, poll);
|
||||||
});
|
}
|
||||||
} else {
|
});
|
||||||
await this.notesRepository.insert(insert);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...insert,
|
...insert,
|
||||||
|
|
|
@ -3,10 +3,12 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { Injectable, Inject } from '@nestjs/common';
|
||||||
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
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 type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
|
||||||
import { RelayService } from '@/core/RelayService.js';
|
import { RelayService } from '@/core/RelayService.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
|
@ -30,6 +32,9 @@ export class NoteDeleteService {
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.db)
|
||||||
|
private db: DataSource,
|
||||||
|
|
||||||
@Inject(DI.meta)
|
@Inject(DI.meta)
|
||||||
private meta: MiMeta,
|
private meta: MiMeta,
|
||||||
|
|
||||||
|
@ -110,9 +115,24 @@ export class NoteDeleteService {
|
||||||
|
|
||||||
this.searchService.unindexNote(note);
|
this.searchService.unindexNote(note);
|
||||||
|
|
||||||
await this.notesRepository.delete({
|
await this.db.transaction(async transaction => {
|
||||||
id: note.id,
|
await transaction.delete(MiNote, {
|
||||||
userId: user.id,
|
id: note.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
await transaction.save(MiDeletedNote, {
|
||||||
|
id: note.id,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
replyId: note.replyId,
|
||||||
|
renoteId: note.renoteId,
|
||||||
|
userId: note.userId,
|
||||||
|
localOnly: note.localOnly,
|
||||||
|
uri: note.uri,
|
||||||
|
url: note.url,
|
||||||
|
channelId: note.channelId,
|
||||||
|
replyUserId: note.replyUserId,
|
||||||
|
renoteUserId: note.renoteUserId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deleter && (note.userId !== deleter.id)) {
|
if (deleter && (note.userId !== deleter.id)) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { MiNote } from '@/models/Note.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 { bindThis } from '@/decorators.js';
|
||||||
import { DebounceLoader } from '@/misc/loader.js';
|
import { DebounceLoader } from '@/misc/loader.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
@ -57,6 +57,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()
|
@Injectable()
|
||||||
export class NoteEntityService implements OnModuleInit {
|
export class NoteEntityService implements OnModuleInit {
|
||||||
private userEntityService: UserEntityService;
|
private userEntityService: UserEntityService;
|
||||||
|
@ -79,6 +91,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.deletedNotesRepository)
|
||||||
|
private deletedNotesRepository: DeletedNotesRepository,
|
||||||
|
|
||||||
@Inject(DI.followingsRepository)
|
@Inject(DI.followingsRepository)
|
||||||
private followingsRepository: FollowingsRepository,
|
private followingsRepository: FollowingsRepository,
|
||||||
|
|
||||||
|
@ -354,21 +369,24 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return fileIds.map(id => packedFiles.get(id)).filter(x => x != null);
|
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
|
@bindThis
|
||||||
public async pack(
|
public async pack(
|
||||||
src: MiNote['id'] | MiNote,
|
src: MiNote['id'] | MiNote,
|
||||||
me?: { id: MiUser['id'] } | null | undefined,
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
options?: {
|
options?: 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'>>
|
|
||||||
};
|
|
||||||
},
|
|
||||||
): Promise<Packed<'Note'>> {
|
): Promise<Packed<'Note'>> {
|
||||||
const opts = Object.assign({
|
const opts = Object.assign({
|
||||||
detail: true,
|
detail: true,
|
||||||
|
@ -377,7 +395,19 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}, options);
|
}, options);
|
||||||
|
|
||||||
const meId = me ? me.id : null;
|
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 host = note.userHost;
|
||||||
|
|
||||||
const bufferedReactions = opts._hint_?.bufferedReactions != null
|
const bufferedReactions = opts._hint_?.bufferedReactions != null
|
||||||
|
@ -447,21 +477,19 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
...(opts.detail ? {
|
...(opts.detail ? {
|
||||||
clippedCount: note.clippedCount,
|
clippedCount: note.clippedCount,
|
||||||
|
|
||||||
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
|
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
|
||||||
reply: (note.replyId && note.reply === null) ? null : note.replyId ? nullIfEntityNotFound(this.pack(note.reply ?? note.replyId, me, {
|
|
||||||
detail: false,
|
detail: false,
|
||||||
skipHide: opts.skipHide,
|
skipHide: opts.skipHide,
|
||||||
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
||||||
_hint_: options?._hint_,
|
_hint_: options?._hint_,
|
||||||
})) : undefined,
|
}) : undefined,
|
||||||
|
|
||||||
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
|
renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, {
|
||||||
renote: (note.renoteId && note.renote === null) ? null : note.renoteId ? nullIfEntityNotFound(this.pack(note.renote ?? note.renoteId, me, {
|
|
||||||
detail: true,
|
detail: true,
|
||||||
skipHide: opts.skipHide,
|
skipHide: opts.skipHide,
|
||||||
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
||||||
_hint_: options?._hint_,
|
_hint_: options?._hint_,
|
||||||
})) : undefined,
|
}) : undefined,
|
||||||
|
|
||||||
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
||||||
|
|
||||||
|
@ -484,9 +512,93 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return packed;
|
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: ['user', 'renote', 'reply', 'channel'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const packedUsers = options?._hint_?.packedUsers;
|
||||||
|
|
||||||
|
const channel = deletedNote.channelId
|
||||||
|
? deletedNote.channel ?? await this.channelsRepository.findOneBy({ id: deletedNote.channelId })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return await awaitAll({
|
||||||
|
id: deletedNote.id,
|
||||||
|
createdAt: this.idService.parse(deletedNote.id).date.toISOString(),
|
||||||
|
deletedAt: deletedNote.deletedAt?.toISOString() ?? undefined,
|
||||||
|
userId: deletedNote.userId,
|
||||||
|
user: packedUsers?.get(deletedNote.userId) ?? this.userEntityService.pack(deletedNote.user ?? deletedNote.userId, 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
|
@bindThis
|
||||||
public async packMany(
|
public async packMany(
|
||||||
notes: MiNote[],
|
notes: (MiNote | MiDeletedNote)[],
|
||||||
me?: { id: MiUser['id'] } | null | undefined,
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
options?: {
|
options?: {
|
||||||
detail?: boolean;
|
detail?: boolean;
|
||||||
|
@ -495,7 +607,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
) {
|
) {
|
||||||
if (notes.length === 0) return [];
|
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 meId = me ? me.id : null;
|
||||||
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
||||||
|
@ -505,7 +619,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
// パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない
|
// パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない
|
||||||
const oldId = this.idService.gen(Date.now() - 2000);
|
const oldId = this.idService.gen(Date.now() - 2000);
|
||||||
|
|
||||||
for (const note of notes) {
|
for (const note of liveNotes) {
|
||||||
if (isPureRenote(note)) {
|
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);
|
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) {
|
if (reactionsCount === 0) {
|
||||||
|
@ -553,9 +667,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
|
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(liveNotes));
|
||||||
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
|
// 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 packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
|
||||||
const users = [
|
const users = [
|
||||||
...notes.map(({ user, userId }) => user ?? userId),
|
...notes.map(({ user, userId }) => user ?? userId),
|
||||||
|
@ -565,7 +679,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
const packedUsers = await this.userEntityService.packMany(users, me)
|
const packedUsers = await this.userEntityService.packMany(users, me)
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
.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,
|
...options,
|
||||||
_hint_: {
|
_hint_: {
|
||||||
bufferedReactions,
|
bufferedReactions,
|
||||||
|
|
|
@ -90,5 +90,6 @@ export const DI = {
|
||||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||||
noteDraftsRepository: Symbol('noteDraftsRepository'),
|
noteDraftsRepository: Symbol('noteDraftsRepository'),
|
||||||
|
deletedNotesRepository: Symbol('deletedNotesRepository'),
|
||||||
//#endregion
|
//#endregion
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Index(['userId', 'id']) // Note: this index is ("userId", "id" DESC) in production, but not in test.
|
||||||
|
@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({
|
||||||
|
...id(),
|
||||||
|
comment: 'The ID of author.',
|
||||||
|
})
|
||||||
|
public userId: MiUser['id'];
|
||||||
|
|
||||||
|
@ManyToOne(type => MiUser, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
public user: MiUser | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,6 +43,7 @@ import {
|
||||||
MiNoteReaction,
|
MiNoteReaction,
|
||||||
MiNoteThreadMuting,
|
MiNoteThreadMuting,
|
||||||
MiNoteDraft,
|
MiNoteDraft,
|
||||||
|
MiDeletedNote,
|
||||||
MiPage,
|
MiPage,
|
||||||
MiPageLike,
|
MiPageLike,
|
||||||
MiPasswordResetRequest,
|
MiPasswordResetRequest,
|
||||||
|
@ -147,6 +148,12 @@ const $noteDraftsRepository: Provider = {
|
||||||
inject: [DI.db],
|
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 = {
|
const $pollsRepository: Provider = {
|
||||||
provide: DI.pollsRepository,
|
provide: DI.pollsRepository,
|
||||||
useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository<MiPoll>),
|
useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository<MiPoll>),
|
||||||
|
@ -550,6 +557,7 @@ const $reversiGamesRepository: Provider = {
|
||||||
$noteThreadMutingsRepository,
|
$noteThreadMutingsRepository,
|
||||||
$noteReactionsRepository,
|
$noteReactionsRepository,
|
||||||
$noteDraftsRepository,
|
$noteDraftsRepository,
|
||||||
|
$deletedNotesRepository,
|
||||||
$pollsRepository,
|
$pollsRepository,
|
||||||
$pollVotesRepository,
|
$pollVotesRepository,
|
||||||
$userProfilesRepository,
|
$userProfilesRepository,
|
||||||
|
@ -627,6 +635,7 @@ const $reversiGamesRepository: Provider = {
|
||||||
$noteThreadMutingsRepository,
|
$noteThreadMutingsRepository,
|
||||||
$noteReactionsRepository,
|
$noteReactionsRepository,
|
||||||
$noteDraftsRepository,
|
$noteDraftsRepository,
|
||||||
|
$deletedNotesRepository,
|
||||||
$pollsRepository,
|
$pollsRepository,
|
||||||
$pollVotesRepository,
|
$pollVotesRepository,
|
||||||
$userProfilesRepository,
|
$userProfilesRepository,
|
||||||
|
|
|
@ -40,6 +40,7 @@ import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
|
||||||
import { MiClip } from '@/models/Clip.js';
|
import { MiClip } from '@/models/Clip.js';
|
||||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||||
import { MiClipNote } from '@/models/ClipNote.js';
|
import { MiClipNote } from '@/models/ClipNote.js';
|
||||||
|
import { MiDeletedNote } from '@/models/DeletedNote.js';
|
||||||
import { MiDriveFile } from '@/models/DriveFile.js';
|
import { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
import { MiDriveFolder } from '@/models/DriveFolder.js';
|
import { MiDriveFolder } from '@/models/DriveFolder.js';
|
||||||
import { MiEmoji } from '@/models/Emoji.js';
|
import { MiEmoji } from '@/models/Emoji.js';
|
||||||
|
@ -175,6 +176,7 @@ export {
|
||||||
MiClip,
|
MiClip,
|
||||||
MiClipNote,
|
MiClipNote,
|
||||||
MiClipFavorite,
|
MiClipFavorite,
|
||||||
|
MiDeletedNote,
|
||||||
MiDriveFile,
|
MiDriveFile,
|
||||||
MiDriveFolder,
|
MiDriveFolder,
|
||||||
MiEmoji,
|
MiEmoji,
|
||||||
|
@ -254,6 +256,7 @@ export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepos
|
||||||
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
|
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
|
||||||
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
|
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
|
||||||
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
|
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
|
||||||
|
export type DeletedNotesRepository = Repository<MiDeletedNote> & MiRepository<MiDeletedNote>;
|
||||||
export type DriveFilesRepository = Repository<MiDriveFile> & MiRepository<MiDriveFile>;
|
export type DriveFilesRepository = Repository<MiDriveFile> & MiRepository<MiDriveFile>;
|
||||||
export type DriveFoldersRepository = Repository<MiDriveFolder> & MiRepository<MiDriveFolder>;
|
export type DriveFoldersRepository = Repository<MiDriveFolder> & MiRepository<MiDriveFolder>;
|
||||||
export type EmojisRepository = Repository<MiEmoji> & MiRepository<MiEmoji>;
|
export type EmojisRepository = Repository<MiEmoji> & MiRepository<MiEmoji>;
|
||||||
|
|
|
@ -46,6 +46,7 @@ import { MiNoteFavorite } from '@/models/NoteFavorite.js';
|
||||||
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||||
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
|
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
|
||||||
import { MiNoteDraft } from '@/models/NoteDraft.js';
|
import { MiNoteDraft } from '@/models/NoteDraft.js';
|
||||||
|
import { MiDeletedNote } from '@/models/DeletedNote.js';
|
||||||
import { MiPage } from '@/models/Page.js';
|
import { MiPage } from '@/models/Page.js';
|
||||||
import { MiPageLike } from '@/models/PageLike.js';
|
import { MiPageLike } from '@/models/PageLike.js';
|
||||||
import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
|
import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
|
||||||
|
@ -212,6 +213,7 @@ export const entities = [
|
||||||
MiNoteReaction,
|
MiNoteReaction,
|
||||||
MiNoteThreadMuting,
|
MiNoteThreadMuting,
|
||||||
MiNoteDraft,
|
MiNoteDraft,
|
||||||
|
MiDeletedNote,
|
||||||
MiPage,
|
MiPage,
|
||||||
MiPageLike,
|
MiPageLike,
|
||||||
MiGalleryPost,
|
MiGalleryPost,
|
||||||
|
|
|
@ -5,10 +5,11 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
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 { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
|
import type { MiDeletedNote } from '@/models/DeletedNote.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
|
@ -21,6 +22,9 @@ export class GetterService {
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.deletedNotesRepository)
|
||||||
|
private deletedNotesRepository: DeletedNotesRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
@ -40,8 +44,17 @@ export class GetterService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getNoteWithRelations(noteId: MiNote['id']) {
|
public async getMayDeletedNoteOrNull(noteId: MiNote['id']): Promise<MiNote | MiDeletedNote | null> {
|
||||||
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
|
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) {
|
if (note == null) {
|
||||||
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
|
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
|
||||||
|
@ -50,6 +63,19 @@ export class GetterService {
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async getMayDeletedNoteWithRelations(noteId: MiNote['id']): Promise<(MiNote | MiDeletedNote) & { user: NonNullable<MiNote['user']> }> {
|
||||||
|
let note: MiNote | MiDeletedNote | null = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
|
||||||
|
|
||||||
|
note ??= await this.deletedNotesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
|
||||||
|
|
||||||
|
if (note == null) {
|
||||||
|
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return note as (MiNote | MiDeletedNote) & { user: object };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user for API processing
|
* Get user for API processing
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -3,12 +3,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { 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 { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
|
||||||
import { GetterService } from '@/server/api/GetterService.js';
|
import { GetterService } from '@/server/api/GetterService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
|
@ -49,24 +48,21 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.notesRepository)
|
|
||||||
private notesRepository: NotesRepository,
|
|
||||||
|
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private getterService: GetterService,
|
private getterService: GetterService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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);
|
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
const conversation: MiNote[] = [];
|
const conversation: (MiNote | MiDeletedNote)[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
const get = async (id: any) => {
|
const get = async (id: MiNote['id']) => {
|
||||||
i++;
|
i++;
|
||||||
const p = await this.notesRepository.findOneBy({ id });
|
const p = await this.getterService.getMayDeletedNoteOrNull(id);
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
|
|
||||||
if (i > ps.offset!) {
|
if (i > ps.offset!) {
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { GetterService } from '@/server/api/GetterService.js';
|
import { GetterService } from '@/server/api/GetterService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { MiMeta } from '@/models/Meta.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';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -55,12 +57,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private getterService: GetterService,
|
private getterService: GetterService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
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);
|
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (note.user!.requireSigninToViewContents && me == null) {
|
if (note.user.requireSigninToViewContents && me == null) {
|
||||||
throw new ApiError(meta.errors.signinRequired);
|
throw new ApiError(meta.errors.signinRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,11 +70,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new ApiError(meta.errors.signinRequired);
|
throw new ApiError(meta.errors.signinRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
|
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.user.host != null && me == null) {
|
||||||
throw new ApiError(meta.errors.signinRequired);
|
throw new ApiError(meta.errors.signinRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.noteEntityService.pack(note, me, {
|
return await this.noteEntityService.packMayDeleted(note, me, {
|
||||||
detail: true,
|
detail: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import assert, { rejects, strictEqual } from 'node:assert';
|
import assert, { notEqual, rejects, strictEqual } from 'node:assert';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||||
|
|
||||||
|
@ -154,13 +154,8 @@ describe('Note', () => {
|
||||||
await bob.client.request('notes/delete', { noteId: note.id });
|
await bob.client.request('notes/delete', { noteId: note.id });
|
||||||
await sleep();
|
await sleep();
|
||||||
|
|
||||||
await rejects(
|
const noteInARemoved = await carol.client.request('notes/show', { noteId: noteInA.id });
|
||||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
notEqual(noteInARemoved.deletedAt, null);
|
||||||
(err: any) => {
|
|
||||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -179,13 +174,8 @@ describe('Note', () => {
|
||||||
await bob.client.request('notes/delete', { noteId: note.id });
|
await bob.client.request('notes/delete', { noteId: note.id });
|
||||||
await sleep();
|
await sleep();
|
||||||
|
|
||||||
await rejects(
|
const noteInARemoved = await alice.client.request('notes/show', { noteId: noteInA.id });
|
||||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
notEqual(noteInARemoved.deletedAt, null);
|
||||||
(err: any) => {
|
|
||||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -199,13 +189,8 @@ describe('Note', () => {
|
||||||
await bob.client.request('notes/delete', { noteId: note.id });
|
await bob.client.request('notes/delete', { noteId: note.id });
|
||||||
await sleep();
|
await sleep();
|
||||||
|
|
||||||
await rejects(
|
const noteInARemoved = await alice.client.request('notes/show', { noteId: noteInA.id });
|
||||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
notEqual(noteInARemoved.deletedAt, null);
|
||||||
(err: any) => {
|
|
||||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -241,13 +226,9 @@ describe('Note', () => {
|
||||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||||
const bMod = await createModerator('b.test');
|
const bMod = await createModerator('b.test');
|
||||||
await bMod.client.request('notes/delete', { noteId: noteInB.id });
|
await bMod.client.request('notes/delete', { noteId: noteInB.id });
|
||||||
await rejects(
|
|
||||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
const noteInBRemoved = await bob.client.request('notes/show', { noteId: noteInB.id });
|
||||||
(err: any) => {
|
notEqual(noteInBRemoved.deletedAt, null);
|
||||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -64,8 +64,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.text">
|
<div :class="$style.text">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
<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>
|
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
|
||||||
|
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
|
||||||
<Mfm
|
<Mfm
|
||||||
v-if="appearNote.text"
|
v-else-if="appearNote.text"
|
||||||
:parsedNodes="parsed"
|
:parsedNodes="parsed"
|
||||||
:text="appearNote.text"
|
:text="appearNote.text"
|
||||||
:author="appearNote.user"
|
:author="appearNote.user"
|
||||||
|
@ -124,12 +125,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
</MkReactionsViewer>
|
</MkReactionsViewer>
|
||||||
<footer :class="$style.footer">
|
<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>
|
<i class="ti ti-arrow-back-up"></i>
|
||||||
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else :class="$style.footerButton" class="_button" disabled>
|
||||||
|
<i class="ti ti-ban"></i>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canRenote"
|
v-if="canRenote && !appearNote.deletedAt"
|
||||||
ref="renoteButton"
|
ref="renoteButton"
|
||||||
:class="$style.footerButton"
|
:class="$style.footerButton"
|
||||||
class="_button"
|
class="_button"
|
||||||
|
@ -141,16 +145,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<button v-else :class="$style.footerButton" class="_button" disabled>
|
<button v-else :class="$style.footerButton" class="_button" disabled>
|
||||||
<i class="ti ti-ban"></i>
|
<i class="ti ti-ban"></i>
|
||||||
</button>
|
</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-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.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-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||||
<i v-else class="ti ti-plus"></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>
|
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else :class="$style.footerButton" class="_button" disabled>
|
||||||
|
<i class="ti ti-ban"></i>
|
||||||
|
</button>
|
||||||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
|
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
|
||||||
<i class="ti ti-paperclip"></i>
|
<i class="ti ti-paperclip"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else-if="appearNote.deletedAt" :class="$style.noteFooterButton" class="_button" disabled>
|
||||||
|
<i class="ti ti-ban"></i>
|
||||||
|
</button>
|
||||||
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
|
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
|
||||||
<i class="ti ti-dots"></i>
|
<i class="ti ti-dots"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -87,8 +87,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-show="appearNote.cw == null || showContent">
|
<div v-show="appearNote.cw == null || showContent">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
<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>
|
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
|
||||||
|
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
|
||||||
<Mfm
|
<Mfm
|
||||||
v-if="appearNote.text"
|
v-else-if="appearNote.text"
|
||||||
:parsedNodes="parsed"
|
:parsedNodes="parsed"
|
||||||
:text="appearNote.text"
|
:text="appearNote.text"
|
||||||
:author="appearNote.user"
|
:author="appearNote.user"
|
||||||
|
@ -142,12 +143,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:maxNumber="16"
|
:maxNumber="16"
|
||||||
@mockUpdateMyReaction="emitUpdReaction"
|
@mockUpdateMyReaction="emitUpdReaction"
|
||||||
/>
|
/>
|
||||||
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
<button v-if="!appearNote.deletedAt" class="_button" :class="$style.noteFooterButton" @click="reply()">
|
||||||
<i class="ti ti-arrow-back-up"></i>
|
<i class="ti ti-arrow-back-up"></i>
|
||||||
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else :class="$style.noteFooterButton" class="_button" disabled>
|
||||||
|
<i class="ti ti-ban"></i>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canRenote"
|
v-if="canRenote && !appearNote.deletedAt"
|
||||||
ref="renoteButton"
|
ref="renoteButton"
|
||||||
class="_button"
|
class="_button"
|
||||||
:class="$style.noteFooterButton"
|
:class="$style.noteFooterButton"
|
||||||
|
@ -159,16 +163,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
||||||
<i class="ti ti-ban"></i>
|
<i class="ti ti-ban"></i>
|
||||||
</button>
|
</button>
|
||||||
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
|
<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-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.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-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||||
<i v-else class="ti ti-plus"></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>
|
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else :class="$style.noteFooterButton" class="_button" disabled>
|
||||||
|
<i class="ti ti-ban"></i>
|
||||||
|
</button>
|
||||||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
|
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
|
||||||
<i class="ti ti-paperclip"></i>
|
<i class="ti ti-paperclip"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else-if="appearNote.deletedAt" :class="$style.noteFooterButton" class="_button" disabled>
|
||||||
|
<i class="ti ti-ban"></i>
|
||||||
|
</button>
|
||||||
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
|
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
|
||||||
<i class="ti ti-dots"></i>
|
<i class="ti ti-dots"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
|
<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
|
||||||
<div>
|
<div>
|
||||||
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
<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>
|
<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"/>
|
<span v-if="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>
|
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
|
||||||
</div>
|
</div>
|
||||||
<details v-if="note.files && note.files.length > 0">
|
<details v-if="note.files && note.files.length > 0">
|
||||||
|
|
|
@ -296,7 +296,29 @@ export function getNoteMenu(props: {
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [];
|
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', {
|
const statePromise = misskeyApi('notes/state', {
|
||||||
noteId: appearNote.id,
|
noteId: appearNote.id,
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue