parent
421d8fb2c2
commit
317cabe15c
@ -1263,6 +1263,7 @@ confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media"
|
||||
sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?"
|
||||
createdLists: "Created lists"
|
||||
createdAntennas: "Created antennas"
|
||||
noteUpdatedAt: "Edited: {date} {time}"
|
||||
_delivery:
|
||||
status: "Delivery status"
|
||||
stop: "Suspended"
|
||||
@ -1706,6 +1707,7 @@ _role:
|
||||
gtlAvailable: "Can view the global timeline"
|
||||
ltlAvailable: "Can view the local timeline"
|
||||
canPublicNote: "Can send public notes"
|
||||
canEditNote: "Note editing"
|
||||
mentionMax: "Maximum number of mentions in a note"
|
||||
canInvite: "Can create instance invite codes"
|
||||
inviteLimit: "Invite limit"
|
||||
|
8
locales/index.d.ts
vendored
8
locales/index.d.ts
vendored
@ -5068,6 +5068,10 @@ export interface Locale extends ILocale {
|
||||
* 作成したアンテナ
|
||||
*/
|
||||
"createdAntennas": string;
|
||||
/**
|
||||
* 編集済み: {date} {time}
|
||||
*/
|
||||
"noteUpdatedAt": ParameterizedString<"date" | "time">;
|
||||
"_delivery": {
|
||||
/**
|
||||
* 配信状態
|
||||
@ -6642,6 +6646,10 @@ export interface Locale extends ILocale {
|
||||
* パブリック投稿の許可
|
||||
*/
|
||||
"canPublicNote": string;
|
||||
/**
|
||||
* ノートの編集
|
||||
*/
|
||||
"canEditNote": string;
|
||||
/**
|
||||
* ノート内の最大メンション数
|
||||
*/
|
||||
|
@ -1263,6 +1263,7 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示
|
||||
sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
|
||||
createdLists: "作成したリスト"
|
||||
createdAntennas: "作成したアンテナ"
|
||||
noteUpdatedAt: "編集済み: {date} {time}"
|
||||
|
||||
_delivery:
|
||||
status: "配信状態"
|
||||
@ -1717,6 +1718,7 @@ _role:
|
||||
gtlAvailable: "グローバルタイムラインの閲覧"
|
||||
ltlAvailable: "ローカルタイムラインの閲覧"
|
||||
canPublicNote: "パブリック投稿の許可"
|
||||
canEditNote: "ノートの編集"
|
||||
mentionMax: "ノート内の最大メンション数"
|
||||
canInvite: "サーバー招待コードの発行"
|
||||
inviteLimit: "招待コードの作成可能数"
|
||||
|
@ -1249,6 +1249,7 @@ alwaysConfirmFollow: "팔로우일 때 항상 확인하기"
|
||||
inquiry: "문의하기"
|
||||
tryAgain: "다시 시도해 주세요."
|
||||
confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인"
|
||||
noteUpdatedAt: "편집됨: {date} {time}"
|
||||
_delivery:
|
||||
status: "전송 상태"
|
||||
stop: "정지됨"
|
||||
@ -1692,6 +1693,7 @@ _role:
|
||||
gtlAvailable: "글로벌 타임라인 보이기"
|
||||
ltlAvailable: "로컬 타임라인 보이기"
|
||||
canPublicNote: "공개 노트 허용"
|
||||
canEditNote: "노트 편집 허용"
|
||||
mentionMax: "노트에 넣을 수 있는 멘션 수"
|
||||
canInvite: "서버 초대 코드 발행"
|
||||
inviteLimit: "초대 한도"
|
||||
|
16
packages/backend/migration/1724072711475-NoteEdit.js
Normal file
16
packages/backend/migration/1724072711475-NoteEdit.js
Normal file
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class NoteEdit1724072711475 {
|
||||
name = 'NoteEdit1724072711475'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "updatedAt"`);
|
||||
}
|
||||
}
|
@ -41,6 +41,7 @@ import { MetaService } from './MetaService.js';
|
||||
import { MfmService } from './MfmService.js';
|
||||
import { ModerationLogService } from './ModerationLogService.js';
|
||||
import { NoteCreateService } from './NoteCreateService.js';
|
||||
import { NoteUpdateService } from './NoteUpdateService.js';
|
||||
import { NoteDeleteService } from './NoteDeleteService.js';
|
||||
import { NotePiningService } from './NotePiningService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
@ -183,6 +184,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic
|
||||
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
|
||||
const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService };
|
||||
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
|
||||
const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService };
|
||||
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
|
||||
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
||||
@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
MfmService,
|
||||
ModerationLogService,
|
||||
NoteCreateService,
|
||||
NoteUpdateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
@ -475,6 +478,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$MfmService,
|
||||
$ModerationLogService,
|
||||
$NoteCreateService,
|
||||
$NoteUpdateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
MfmService,
|
||||
ModerationLogService,
|
||||
NoteCreateService,
|
||||
NoteUpdateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
@ -763,6 +768,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$MfmService,
|
||||
$ModerationLogService,
|
||||
$NoteCreateService,
|
||||
$NoteUpdateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
|
@ -119,7 +119,10 @@ export interface NoteEventTypes {
|
||||
};
|
||||
updated: {
|
||||
cw: string | null;
|
||||
text: string;
|
||||
text: string | null;
|
||||
files: Packed<'DriveFile'>[];
|
||||
fileIds: string[];
|
||||
poll: any | null;
|
||||
};
|
||||
reacted: {
|
||||
reaction: string;
|
||||
|
@ -128,6 +128,7 @@ type MinimumUser = {
|
||||
|
||||
type Option = {
|
||||
createdAt?: Date | null;
|
||||
updatedAt?: Date | null;
|
||||
name?: string | null;
|
||||
text?: string | null;
|
||||
reply?: MiNote | null;
|
||||
|
308
packages/backend/src/core/NoteUpdateService.ts
Normal file
308
packages/backend/src/core/NoteUpdateService.ts
Normal file
@ -0,0 +1,308 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import util from 'util';
|
||||
import { In, DataSource } from 'typeorm';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as mfm from 'mfm-js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { MiDriveFile, MiPollVote } from '@/models/_.js';
|
||||
import { MiPoll, IPoll } from '@/models/Poll.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
|
||||
type MinimumUser = {
|
||||
id: MiUser['id'];
|
||||
host: MiUser['host'];
|
||||
username: MiUser['username'];
|
||||
uri: MiUser['uri'];
|
||||
};
|
||||
|
||||
type Option = {
|
||||
updatedAt?: Date | null;
|
||||
files?: MiDriveFile[] | null;
|
||||
name?: string | null;
|
||||
text?: string | null;
|
||||
cw?: string | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
poll?: IPoll | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NoteUpdateService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private relayService: RelayService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
private searchService: SearchService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
) { }
|
||||
|
||||
@bindThis
|
||||
public async update(user: {
|
||||
id: MiUser['id'];
|
||||
username: MiUser['username'];
|
||||
host: MiUser['host'];
|
||||
isBot: MiUser['isBot'];
|
||||
}, data: Option, note: MiNote, silent = false): Promise<MiNote | null> {
|
||||
if (data.updatedAt == null) data.updatedAt = new Date();
|
||||
|
||||
if (data.text) {
|
||||
if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) {
|
||||
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
|
||||
}
|
||||
data.text = data.text.trim();
|
||||
} else {
|
||||
data.text = null;
|
||||
}
|
||||
|
||||
let tags = data.apHashtags;
|
||||
let emojis = data.apEmojis;
|
||||
|
||||
// Parse MFM if needed
|
||||
if (!tags || !emojis) {
|
||||
const tokens = data.text ? mfm.parse(data.text)! : [];
|
||||
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
|
||||
const choiceTokens = data.poll && data.poll.choices
|
||||
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
|
||||
: [];
|
||||
|
||||
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
||||
|
||||
tags = data.apHashtags ?? extractHashtags(combinedTokens);
|
||||
|
||||
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
|
||||
}
|
||||
|
||||
tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32);
|
||||
|
||||
const updatedNote = await this.updateNote(user, note, data, tags, emojis);
|
||||
|
||||
if (updatedNote) {
|
||||
setImmediate('post updated', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteUpdated(updatedNote, user, silent),
|
||||
() => { /* aborted, ignore this */ },
|
||||
);
|
||||
}
|
||||
|
||||
return updatedNote;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async updateNote(user: {
|
||||
id: MiUser['id']; host: MiUser['host'];
|
||||
}, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise<MiNote | null> {
|
||||
const values = new MiNote({
|
||||
updatedAt: data.updatedAt!,
|
||||
fileIds: data.files ? data.files.map(file => file.id) : [],
|
||||
text: data.text,
|
||||
hasPoll: data.poll != null,
|
||||
cw: data.cw ?? null,
|
||||
tags: tags.map(tag => normalizeForSearch(tag)),
|
||||
emojis,
|
||||
attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
|
||||
});
|
||||
|
||||
// 投稿を更新
|
||||
try {
|
||||
if (note.hasPoll && values.hasPoll) {
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.update(MiNote, { id: note.id }, values);
|
||||
|
||||
if (values.hasPoll) {
|
||||
const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id });
|
||||
if (old_poll?.choices.toString() !== data.poll?.choices.toString() || old_poll?.multiple !== data.poll?.multiple) {
|
||||
await transactionalEntityManager.delete(MiPoll, { noteId: note.id });
|
||||
await transactionalEntityManager.delete(MiPollVote, { noteId: note.id });
|
||||
const poll = new MiPoll({
|
||||
noteId: note.id,
|
||||
choices: data.poll?.choices,
|
||||
expiresAt: data.poll?.expiresAt,
|
||||
multiple: data.poll?.multiple,
|
||||
votes: new Array(data.poll?.choices.length).fill(0),
|
||||
noteVisibility: note.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
});
|
||||
await transactionalEntityManager.insert(MiPoll, poll);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (!note.hasPoll && values.hasPoll) {
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.update(MiNote, { id: note.id }, values);
|
||||
|
||||
if (values.hasPoll) {
|
||||
const poll = new MiPoll({
|
||||
noteId: note.id,
|
||||
choices: data.poll?.choices,
|
||||
expiresAt: data.poll?.expiresAt,
|
||||
multiple: data.poll?.multiple,
|
||||
votes: new Array(data.poll?.choices.length).fill(0),
|
||||
noteVisibility: note.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(MiPoll, poll);
|
||||
}
|
||||
});
|
||||
} else if (note.hasPoll && !values.hasPoll) {
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.update(MiNote, { id: note.id }, values);
|
||||
|
||||
if (!values.hasPoll) {
|
||||
await transactionalEntityManager.delete(MiPoll, { noteId: note.id });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.notesRepository.update({ id: note.id }, values);
|
||||
}
|
||||
|
||||
return await this.notesRepository.findOneBy({ id: note.id });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async postNoteUpdated(note: MiNote, user: {
|
||||
id: MiUser['id'];
|
||||
username: MiUser['username'];
|
||||
host: MiUser['host'];
|
||||
isBot: MiUser['isBot'];
|
||||
}, silent: boolean) {
|
||||
if (!silent) {
|
||||
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
|
||||
|
||||
const noteObj = await this.noteEntityService.pack(note, user);
|
||||
|
||||
console.log(noteObj);
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', {
|
||||
cw: noteObj.cw ?? null,
|
||||
text: noteObj.text,
|
||||
files: noteObj.files ?? [],
|
||||
fileIds: noteObj.fileIds ?? [],
|
||||
poll: noteObj.poll ?? null,
|
||||
});
|
||||
|
||||
//#region AP deliver
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
await (async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const noteActivity = await this.renderNoteActivity(note, user);
|
||||
|
||||
await this.deliverToConcerned(user, note, noteActivity);
|
||||
})();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
// Register to search database
|
||||
this.reIndex(note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async renderNoteActivity(note: MiNote, user: MiUser) {
|
||||
const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user);
|
||||
|
||||
return this.apRendererService.addContext(content);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getMentionedRemoteUsers(note: MiNote) {
|
||||
const where = [] as any[];
|
||||
|
||||
// mention / reply / dm
|
||||
const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
|
||||
if (uris.length > 0) {
|
||||
where.push(
|
||||
{ uri: In(uris) },
|
||||
);
|
||||
}
|
||||
|
||||
// renote / quote
|
||||
if (note.renoteUserId) {
|
||||
where.push({
|
||||
id: note.renoteUserId,
|
||||
});
|
||||
}
|
||||
|
||||
if (where.length === 0) return [];
|
||||
|
||||
return await this.usersRepository.find({
|
||||
where,
|
||||
}) as MiRemoteUser[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) {
|
||||
await this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
await this.relayService.deliverToRelays(user, content);
|
||||
const remoteUsers = await this.getMentionedRemoteUsers(note);
|
||||
for (const remoteUser of remoteUsers) {
|
||||
await this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private reIndex(note: MiNote) {
|
||||
if (note.text == null && note.cw == null) return;
|
||||
|
||||
this.searchService.unindexNote(note);
|
||||
this.searchService.indexNote(note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
@ -35,6 +35,7 @@ export type RolePolicies = {
|
||||
gtlAvailable: boolean;
|
||||
ltlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
canEditNote: boolean;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
@ -64,6 +65,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||
gtlAvailable: true,
|
||||
ltlAvailable: true,
|
||||
canPublicNote: true,
|
||||
canEditNote: true,
|
||||
mentionLimit: 20,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
@ -364,6 +366,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
|
||||
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
|
||||
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
|
||||
canEditNote: calc('canEditNote', vs => vs.some(v => v === true)),
|
||||
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
|
||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||
|
@ -14,6 +14,7 @@ import { NotePiningService } from '@/core/NotePiningService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import { NoteUpdateService } from '@/core/NoteUpdateService.js';
|
||||
import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
@ -73,6 +74,7 @@ export class ApInboxService {
|
||||
private notePiningService: NotePiningService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
private noteUpdateService: NoteUpdateService,
|
||||
private noteDeleteService: NoteDeleteService,
|
||||
private appLockService: AppLockService,
|
||||
private apResolverService: ApResolverService,
|
||||
@ -751,11 +753,13 @@ export class ApInboxService {
|
||||
|
||||
@bindThis
|
||||
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
this.logger.debug('Update');
|
||||
this.logger.debug(`Update: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
@ -767,6 +771,9 @@ export class ApInboxService {
|
||||
if (isActor(object)) {
|
||||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||
return 'ok: Person updated';
|
||||
} else if (getApType(object) === 'Note') {
|
||||
await this.updateNote(resolver, actor, object, false, activity);
|
||||
return 'ok: Note updated';
|
||||
} else if (getApType(object) === 'Question') {
|
||||
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
|
||||
return 'ok: Question updated';
|
||||
@ -775,6 +782,40 @@ export class ApInboxService {
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async updateNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: IUpdate): Promise<string> {
|
||||
const uri = getApId(note);
|
||||
|
||||
if (typeof note === 'object') {
|
||||
if (actor.uri !== note.attributedTo) {
|
||||
return 'skip: actor.uri !== note.attributedTo';
|
||||
}
|
||||
|
||||
if (typeof note.id === 'string') {
|
||||
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
||||
return 'skip: host in actor.uri !== note.id';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
const target = await this.notesRepository.findOneBy({ uri: uri });
|
||||
if (!target) return `skip: target note not located: ${uri}`;
|
||||
await this.apNoteService.updateNote(note, target, resolver, silent);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
if (err instanceof StatusError && err.isClientError) {
|
||||
return `skip ${err.statusCode}`;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
|
||||
// fetch the new and old accounts
|
||||
|
@ -108,6 +108,7 @@ export class ApRendererService {
|
||||
actor: this.userEntityService.genLocalUserUri(note.userId),
|
||||
type: 'Announce',
|
||||
published: this.idService.parse(note.id).date.toISOString(),
|
||||
updated: note.updatedAt?.toISOString() ?? undefined,
|
||||
to,
|
||||
cc,
|
||||
object,
|
||||
@ -438,6 +439,7 @@ export class ApRendererService {
|
||||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
published: this.idService.parse(note.id).date.toISOString(),
|
||||
updated: note.updatedAt?.toISOString() ?? undefined,
|
||||
to,
|
||||
cc,
|
||||
inReplyTo,
|
||||
|
@ -6,7 +6,7 @@
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { PollsRepository, EmojisRepository } from '@/models/_.js';
|
||||
import type { NotesRepository, PollsRepository, EmojisRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
@ -37,6 +37,7 @@ import { ApQuestionService } from './ApQuestionService.js';
|
||||
import { ApImageService } from './ApImageService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IPost } from '../type.js';
|
||||
import { NoteUpdateService } from '@/core/NoteUpdateService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApNoteService {
|
||||
@ -52,6 +53,9 @@ export class ApNoteService {
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private apMfmService: ApMfmService,
|
||||
private apResolverService: ApResolverService,
|
||||
@ -69,6 +73,7 @@ export class ApNoteService {
|
||||
private appLockService: AppLockService,
|
||||
private pollService: PollService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
private noteUpdateService: NoteUpdateService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
@ -296,6 +301,7 @@ export class ApNoteService {
|
||||
try {
|
||||
return await this.noteCreateService.create(actor, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
updatedAt: note.updated ? new Date(note.updated) : null,
|
||||
files,
|
||||
reply,
|
||||
renote: quote,
|
||||
@ -325,6 +331,85 @@ export class ApNoteService {
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateNote(value: string | IObject, target: MiNote, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
const entryUri = getApId(value);
|
||||
|
||||
const err = this.validateNote(object, entryUri);
|
||||
if (err) {
|
||||
this.logger.error(err.message, {
|
||||
resolver: { history: resolver.getHistory() },
|
||||
value,
|
||||
object,
|
||||
});
|
||||
throw new Error('invalid note');
|
||||
}
|
||||
|
||||
const note = object as IPost;
|
||||
|
||||
// 投稿者をフェッチ
|
||||
if (note.attributedTo == null) {
|
||||
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
|
||||
}
|
||||
|
||||
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser;
|
||||
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new Error('actor has been suspended');
|
||||
}
|
||||
|
||||
const files: MiDriveFile[] = [];
|
||||
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, attach);
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => {
|
||||
this.logger.info(`extractEmojis: ${e}`);
|
||||
return [];
|
||||
});
|
||||
|
||||
const apEmojis = emojis.map(emoji => emoji.name);
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
try {
|
||||
return await this.noteUpdateService.update(actor, {
|
||||
updatedAt: note.updated ? new Date(note.updated) : null,
|
||||
files,
|
||||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
apHashtags,
|
||||
apEmojis,
|
||||
poll,
|
||||
}, target, silent);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`note update failed: ${err}`);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Noteを解決します。
|
||||
*
|
||||
|
@ -14,6 +14,7 @@ export interface IObject {
|
||||
summary?: string;
|
||||
_misskey_summary?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
cc?: ApObject;
|
||||
to?: ApObject;
|
||||
attributedTo?: ApObject;
|
||||
|
@ -324,6 +324,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
createdAt: this.idService.parse(note.id).date.toISOString(),
|
||||
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
|
||||
userId: note.userId,
|
||||
user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me),
|
||||
text: text,
|
||||
|
@ -229,6 +229,11 @@ export class MiNote {
|
||||
comment: '[Denormalized]',
|
||||
})
|
||||
public renoteUserHost: string | null;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
default: null,
|
||||
})
|
||||
public updatedAt: Date | null;
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
|
@ -17,6 +17,11 @@ export const packedNoteSchema = {
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
deletedAt: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
|
@ -180,6 +180,10 @@ export const packedRolePoliciesSchema = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
canEditNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
mentionLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
|
@ -278,6 +278,7 @@ import * as ep___notes_children from './endpoints/notes/children.js';
|
||||
import * as ep___notes_clips from './endpoints/notes/clips.js';
|
||||
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
|
||||
import * as ep___notes_create from './endpoints/notes/create.js';
|
||||
import * as ep___notes_update from './endpoints/notes/update.js';
|
||||
import * as ep___notes_delete from './endpoints/notes/delete.js';
|
||||
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
|
||||
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
|
||||
@ -661,6 +662,7 @@ const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep__
|
||||
const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default };
|
||||
const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default };
|
||||
const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default };
|
||||
const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default };
|
||||
const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default };
|
||||
const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default };
|
||||
const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default };
|
||||
@ -1048,6 +1050,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$notes_clips,
|
||||
$notes_conversation,
|
||||
$notes_create,
|
||||
$notes_update,
|
||||
$notes_delete,
|
||||
$notes_favorites_create,
|
||||
$notes_favorites_delete,
|
||||
@ -1429,6 +1432,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$notes_clips,
|
||||
$notes_conversation,
|
||||
$notes_create,
|
||||
$notes_update,
|
||||
$notes_delete,
|
||||
$notes_favorites_create,
|
||||
$notes_favorites_delete,
|
||||
|
@ -284,6 +284,7 @@ import * as ep___notes_children from './endpoints/notes/children.js';
|
||||
import * as ep___notes_clips from './endpoints/notes/clips.js';
|
||||
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
|
||||
import * as ep___notes_create from './endpoints/notes/create.js';
|
||||
import * as ep___notes_update from './endpoints/notes/update.js';
|
||||
import * as ep___notes_delete from './endpoints/notes/delete.js';
|
||||
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
|
||||
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
|
||||
@ -665,6 +666,7 @@ const eps = [
|
||||
['notes/clips', ep___notes_clips],
|
||||
['notes/conversation', ep___notes_conversation],
|
||||
['notes/create', ep___notes_create],
|
||||
['notes/update', ep___notes_update],
|
||||
['notes/delete', ep___notes_delete],
|
||||
['notes/favorites/create', ep___notes_favorites_create],
|
||||
['notes/favorites/delete', ep___notes_favorites_delete],
|
||||
|
165
packages/backend/src/server/api/endpoints/notes/update.ts
Normal file
165
packages/backend/src/server/api/endpoints/notes/update.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import ms from 'ms';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { NoteUpdateService } from '@/core/NoteUpdateService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: true,
|
||||
requireRolePolicy: 'canEditNote',
|
||||
|
||||
kind: 'write:notes',
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 10,
|
||||
minInterval: ms('1sec'),
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchNote: {
|
||||
message: 'No such note.',
|
||||
code: 'NO_SUCH_NOTE',
|
||||
id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474',
|
||||
},
|
||||
noSuchFile: {
|
||||
message: 'Some files are not found.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
|
||||
},
|
||||
cannotCreateAlreadyExpiredPoll: {
|
||||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
id: '04da457d-b083-4055-9082-955525eda5a5',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: MAX_NOTE_TEXT_LENGTH,
|
||||
nullable: true,
|
||||
},
|
||||
fileIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
mediaIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
poll: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
choices: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 2,
|
||||
maxItems: 10,
|
||||
items: { type: 'string', minLength: 1, maxLength: 50 },
|
||||
},
|
||||
multiple: { type: 'boolean' },
|
||||
expiresAt: { type: 'integer', nullable: true },
|
||||
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
|
||||
},
|
||||
required: ['choices'],
|
||||
},
|
||||
cw: { type: 'string', nullable: true, maxLength: 100 },
|
||||
disableRightClick: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['noteId', 'text', 'cw'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private getterService: GetterService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private noteUpdateService: NoteUpdateService,
|
||||
) {
|
||||
super({
|
||||
...meta,
|
||||
requireRolePolicy: 'canEditNote',
|
||||
}, paramDef, async (ps, me) => {
|
||||
const note = await this.getterService.getNote(ps.noteId).catch(err => {
|
||||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (note.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.noSuchNote);
|
||||
}
|
||||
|
||||
let files: MiDriveFile[] = [];
|
||||
const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
|
||||
if (fileIds != null) {
|
||||
files = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
|
||||
userId: me.id,
|
||||
fileIds,
|
||||
})
|
||||
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
|
||||
.setParameters({ fileIds })
|
||||
.getMany();
|
||||
|
||||
if (files.length !== fileIds.length) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.poll) {
|
||||
if (typeof ps.poll.expiresAt === 'number') {
|
||||
if (ps.poll.expiresAt < Date.now()) {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
|
||||
}
|
||||
} else if (typeof ps.poll.expiredAfter === 'number') {
|
||||
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
text: ps.text,
|
||||
files: files,
|
||||
cw: ps.cw,
|
||||
poll: ps.poll ? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple ?? false,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
const updatedNote = await this.noteUpdateService.update(me, data, note, false);
|
||||
|
||||
return {
|
||||
updatedNote: await this.noteEntityService.pack(updatedNote!, me),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
@ -60,6 +60,7 @@ describe('NoteCreateService', () => {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const poll: IPoll = {
|
||||
|
@ -43,6 +43,7 @@ const base: MiNote = {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
describe('misc:is-renote', () => {
|
||||
|
@ -17,19 +17,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<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">
|
||||
<span v-if="note.updatedAt" style="margin-right: 0.5em;"><i v-tooltip="i18n.tsx.noteUpdatedAt({ date: (new Date(note.updatedAt)).toLocaleDateString(), time: (new Date(note.updatedAt)).toLocaleTimeString() })" class="ti ti-pencil"></i></span>
|
||||
<span v-if="note.visibility !== 'public'" style="margin-right: 0.5em;">
|
||||
<i v-if="note.visibility === 'home'" v-tooltip="i18n.ts._visibility[note.visibility]" class="ti ti-home"></i>
|
||||
<i v-else-if="note.visibility === 'followers'" v-tooltip="i18n.ts._visibility[note.visibility]" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" v-tooltip="i18n.ts._visibility[note.visibility]" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.reactionAcceptance != null" style="margin-right: 0.5em;" :class="{ [$style.danger]: ['nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', 'likeOnly'].includes(<string>note.reactionAcceptance) }" :title="i18n.ts.reactionAcceptance">
|
||||
<i v-if="note.reactionAcceptance === 'likeOnlyForRemote'" v-tooltip="i18n.ts.likeOnlyForRemote" class="ti ti-heart-plus"></i>
|
||||
<i v-else-if="note.reactionAcceptance === 'nonSensitiveOnly'" v-tooltip="i18n.ts.nonSensitiveOnly" class="ti ti-icons"></i>
|
||||
<i v-else-if="note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'" v-tooltip="i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote" class="ti ti-heart-plus"></i>
|
||||
<i v-else-if="note.reactionAcceptance === 'likeOnly'" v-tooltip="i18n.ts.likeOnly" class="ti ti-heart"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-right: 0.5em;"><i v-tooltip="i18n.ts._visibility['disableFederation']" class="ti ti-rocket-off"></i></span>
|
||||
<span v-if="note.channel" style="margin-right: 0.5em;"><i v-tooltip="note.channel.name" class="ti ti-device-tv"></i></span>
|
||||
<div v-if="mock">
|
||||
<MkTime :time="note.createdAt" colored/>
|
||||
</div>
|
||||
<MkA v-else :to="notePage(note)">
|
||||
<MkTime :time="note.createdAt" colored/>
|
||||
</MkA>
|
||||
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
|
||||
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
|
||||
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
@ -151,6 +151,7 @@ const props = withDefaults(defineProps<{
|
||||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
freezeAfterPosted?: boolean;
|
||||
updateMode?: boolean;
|
||||
mock?: boolean;
|
||||
}>(), {
|
||||
initialVisibleUsers: () => [],
|
||||
@ -707,6 +708,7 @@ function saveDraft() {
|
||||
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
noteId: props.updateMode ? props.initialNote?.id : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@ -788,6 +790,7 @@ async function post(ev?: MouseEvent) {
|
||||
visibility: visibility.value,
|
||||
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
noteId: props.updateMode ? props.initialNote?.id : undefined,
|
||||
};
|
||||
|
||||
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
|
||||
@ -824,7 +827,7 @@ async function post(ev?: MouseEvent) {
|
||||
}
|
||||
|
||||
posting.value = true;
|
||||
misskeyApi('notes/create', postData, token).then(() => {
|
||||
misskeyApi(props.updateMode ? 'notes/update' : 'notes/create', postData, token).then(() => {
|
||||
if (props.freezeAfterPosted) {
|
||||
posted.value = true;
|
||||
} else {
|
||||
|
@ -31,6 +31,7 @@ const props = withDefaults(defineProps<{
|
||||
instant?: boolean;
|
||||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
updateMode?: boolean;
|
||||
}>(), {
|
||||
initialLocalOnly: undefined,
|
||||
});
|
||||
|
@ -75,6 +75,7 @@ export const ROLE_POLICIES = [
|
||||
'gtlAvailable',
|
||||
'ltlAvailable',
|
||||
'canPublicNote',
|
||||
'canEditNote',
|
||||
'mentionLimit',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
|
@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canEditNote, 'canEditNote'])">
|
||||
<template #label>{{ i18n.ts._role._options.canEditNote }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.canEditNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.canEditNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canEditNote)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.canEditNote.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkSwitch v-model="role.policies.canEditNote.value" :disabled="role.policies.canEditNote.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
<MkRange v-model="role.policies.canEditNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>
|
||||
|
@ -48,6 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canEditNote, 'canEditNote'])">
|
||||
<template #label>{{ i18n.ts._role._options.canEditNote }}</template>
|
||||
<template #suffix>{{ policies.canEditNote ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
<MkSwitch v-model="policies.canEditNote">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>{{ policies.mentionLimit }}</template>
|
||||
|
@ -162,6 +162,10 @@ export function getNoteMenu(props: {
|
||||
|
||||
const cleanups = [] as (() => void)[];
|
||||
|
||||
function edit(): void {
|
||||
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel, updateMode: true });
|
||||
}
|
||||
|
||||
function del(): void {
|
||||
os.confirm({
|
||||
type: 'warning',
|
||||
@ -415,6 +419,11 @@ export function getNoteMenu(props: {
|
||||
),
|
||||
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
|
||||
{ type: 'divider' },
|
||||
appearNote.userId === $i.id && $i.policies.canEditNote ? {
|
||||
icon: 'ti ti-edit',
|
||||
text: i18n.ts.edit,
|
||||
action: edit,
|
||||
} : undefined,
|
||||
appearNote.userId === $i.id ? {
|
||||
icon: 'ti ti-edit',
|
||||
text: i18n.ts.deleteAndEdit,
|
||||
|
@ -75,6 +75,16 @@ export function useNoteCapture(props: {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'updated': {
|
||||
note.value.updatedAt = new Date().toISOString();
|
||||
note.value.cw = body.cw;
|
||||
note.value.text = body.text;
|
||||
note.value.files = body.files;
|
||||
note.value.fileIds = body.fileIds;
|
||||
note.value.poll = body.poll;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'deleted': {
|
||||
props.isDeletedRef.value = true;
|
||||
break;
|
||||
|
@ -1599,6 +1599,7 @@ declare namespace entities {
|
||||
NotesConversationResponse,
|
||||
NotesCreateRequest,
|
||||
NotesCreateResponse,
|
||||
NotesUpdateRequest,
|
||||
NotesDeleteRequest,
|
||||
NotesFavoritesCreateRequest,
|
||||
NotesFavoritesDeleteRequest,
|
||||
@ -2689,6 +2690,9 @@ type NotesTranslateResponse = operations['notes___translate']['responses']['200'
|
||||
// @public (undocumented)
|
||||
type NotesUnrenoteRequest = operations['notes___unrenote']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type NotesUpdateRequest = operations['notes___update']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type NotesUserListTimelineRequest = operations['notes___user-list-timeline']['requestBody']['content']['application/json'];
|
||||
|
||||
|
@ -3040,6 +3040,17 @@ declare module '../api.js' {
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:notes*
|
||||
*/
|
||||
request<E extends 'notes/update', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
|
@ -408,6 +408,7 @@ import type {
|
||||
NotesConversationResponse,
|
||||
NotesCreateRequest,
|
||||
NotesCreateResponse,
|
||||
NotesUpdateRequest,
|
||||
NotesDeleteRequest,
|
||||
NotesFavoritesCreateRequest,
|
||||
NotesFavoritesDeleteRequest,
|
||||
@ -846,6 +847,7 @@ export type Endpoints = {
|
||||
'notes/clips': { req: NotesClipsRequest; res: NotesClipsResponse };
|
||||
'notes/conversation': { req: NotesConversationRequest; res: NotesConversationResponse };
|
||||
'notes/create': { req: NotesCreateRequest; res: NotesCreateResponse };
|
||||
'notes/update': { req: NotesUpdateRequest; res: EmptyResponse };
|
||||
'notes/delete': { req: NotesDeleteRequest; res: EmptyResponse };
|
||||
'notes/favorites/create': { req: NotesFavoritesCreateRequest; res: EmptyResponse };
|
||||
'notes/favorites/delete': { req: NotesFavoritesDeleteRequest; res: EmptyResponse };
|
||||
@ -1228,6 +1230,7 @@ export const endpointReqTypes: Record<keyof Endpoints, 'application/json' | 'mul
|
||||
'notes/clips': 'application/json',
|
||||
'notes/conversation': 'application/json',
|
||||
'notes/create': 'application/json',
|
||||
'notes/update': 'application/json',
|
||||
'notes/delete': 'application/json',
|
||||
'notes/favorites/create': 'application/json',
|
||||
'notes/favorites/delete': 'application/json',
|
||||
|
@ -411,6 +411,7 @@ export type NotesConversationRequest = operations['notes___conversation']['reque
|
||||
export type NotesConversationResponse = operations['notes___conversation']['responses']['200']['content']['application/json'];
|
||||
export type NotesCreateRequest = operations['notes___create']['requestBody']['content']['application/json'];
|
||||
export type NotesCreateResponse = operations['notes___create']['responses']['200']['content']['application/json'];
|
||||
export type NotesUpdateRequest = operations['notes___update']['requestBody']['content']['application/json'];
|
||||
export type NotesDeleteRequest = operations['notes___delete']['requestBody']['content']['application/json'];
|
||||
export type NotesFavoritesCreateRequest = operations['notes___favorites___create']['requestBody']['content']['application/json'];
|
||||
export type NotesFavoritesDeleteRequest = operations['notes___favorites___delete']['requestBody']['content']['application/json'];
|
||||
|
@ -2631,6 +2631,15 @@ export type paths = {
|
||||
*/
|
||||
post: operations['notes___create'];
|
||||
};
|
||||
'/notes/update': {
|
||||
/**
|
||||
* notes/update
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:notes*
|
||||
*/
|
||||
post: operations['notes___update'];
|
||||
};
|
||||
'/notes/delete': {
|
||||
/**
|
||||
* notes/delete
|
||||
@ -4036,6 +4045,8 @@ export type components = {
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string | null;
|
||||
/** Format: date-time */
|
||||
deletedAt?: string | null;
|
||||
text: string | null;
|
||||
cw?: string | null;
|
||||
@ -4778,6 +4789,7 @@ export type components = {
|
||||
gtlAvailable: boolean;
|
||||
ltlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
canEditNote: boolean | null;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
@ -21288,6 +21300,76 @@ export type operations = {
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* notes/update
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:notes*
|
||||
*/
|
||||
notes___update: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
noteId: string;
|
||||
text: string | null;
|
||||
fileIds?: string[];
|
||||
mediaIds?: string[];
|
||||
poll?: ({
|
||||
choices: string[];
|
||||
multiple?: boolean;
|
||||
expiresAt?: number | null;
|
||||
expiredAfter?: number | null;
|
||||
}) | null;
|
||||
cw: string | null;
|
||||
/** @default false */
|
||||
disableRightClick?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (without any results) */
|
||||
204: {
|
||||
content: never;
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description To many requests */
|
||||
429: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* notes/delete
|
||||
* @description No description provided.
|
||||
|
@ -251,6 +251,12 @@ export type NoteUpdatedEvent = {
|
||||
body: {
|
||||
deletedAt: string;
|
||||
};
|
||||
} | {
|
||||
type: 'updated';
|
||||
body: {
|
||||
cw: string | null;
|
||||
text: string;
|
||||
};
|
||||
} | {
|
||||
type: 'pollVoted';
|
||||
body: {
|
||||
|
Loading…
Reference in New Issue
Block a user