feat: editing note content (local)
This commit is contained in:
parent
640c579dcd
commit
6d834d40b1
2
locales/index.d.ts
vendored
2
locales/index.d.ts
vendored
|
@ -1128,6 +1128,7 @@ export interface Locale {
|
|||
"authentication": string;
|
||||
"authenticationRequiredToContinue": string;
|
||||
"dateAndTime": string;
|
||||
"edited": string;
|
||||
"_announcement": {
|
||||
"forExistingUsers": string;
|
||||
"forExistingUsersDescription": string;
|
||||
|
@ -1541,6 +1542,7 @@ export interface Locale {
|
|||
"gtlAvailable": string;
|
||||
"ltlAvailable": string;
|
||||
"canPublicNote": string;
|
||||
"canEditNote": string;
|
||||
"canInvite": string;
|
||||
"inviteLimit": string;
|
||||
"inviteLimitCycle": string;
|
||||
|
|
|
@ -1125,6 +1125,7 @@ unnotifyNotes: "投稿の通知を解除"
|
|||
authentication: "認証"
|
||||
authenticationRequiredToContinue: "続けるには認証を行ってください"
|
||||
dateAndTime: "日時"
|
||||
edited: "編集済み"
|
||||
|
||||
_announcement:
|
||||
forExistingUsers: "既存ユーザーのみ"
|
||||
|
@ -1462,6 +1463,7 @@ _role:
|
|||
gtlAvailable: "グローバルタイムラインの閲覧"
|
||||
ltlAvailable: "ローカルタイムラインの閲覧"
|
||||
canPublicNote: "パブリック投稿の許可"
|
||||
canEditNote: "ノートの編集"
|
||||
canInvite: "サーバー招待コードの発行"
|
||||
inviteLimit: "招待コードの作成可能数"
|
||||
inviteLimitCycle: "招待コードの発行間隔"
|
||||
|
|
|
@ -1111,6 +1111,7 @@ youHaveUnreadAnnouncements: "읽지 않은 공지사항이 있습니다."
|
|||
useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 주십시오."
|
||||
replies: "답글"
|
||||
renotes: "리노트"
|
||||
edited: "편집됨"
|
||||
_announcement:
|
||||
forExistingUsers: "기존 유저에게만 알림"
|
||||
forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다."
|
||||
|
@ -1431,6 +1432,7 @@ _role:
|
|||
gtlAvailable: "글로벌 타임라인 보이기"
|
||||
ltlAvailable: "로컬 타임라인 보이기"
|
||||
canPublicNote: "공개 노트 허용"
|
||||
canEditNote: "노트 편집 허용"
|
||||
canInvite: "서버 초대 코드 발행"
|
||||
inviteLimit: "초대 한도"
|
||||
inviteLimitCycle: "초대 발급 간격"
|
||||
|
|
|
@ -19,7 +19,10 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { SearchService } from '@/core/SearchService.js';
|
||||
|
||||
type Option = {
|
||||
visibility: string;
|
||||
cw?: string | null;
|
||||
text?: string;
|
||||
visibility?: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
@ -53,37 +56,34 @@ export class NoteUpdateService {
|
|||
|
||||
@bindThis
|
||||
async update(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, data: Option) {
|
||||
const updatedAt = new Date();
|
||||
const cascadingNotes = await this.findCascadingNotes(note);
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', {
|
||||
updatedAt: updatedAt,
|
||||
visibility: data.visibility,
|
||||
});
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', data);
|
||||
|
||||
for (const cascadingNote of cascadingNotes) {
|
||||
if (cascadingNote.visibility === 'public' && data.visibility !== 'public') {
|
||||
this.globalEventService.publishNoteStream(cascadingNote.id, 'updated', {
|
||||
updatedAt: updatedAt,
|
||||
visibility: data.visibility,
|
||||
});
|
||||
if (data.visibility !== undefined) {
|
||||
for (const cascadingNote of cascadingNotes) {
|
||||
if (cascadingNote.visibility === 'public' && data.visibility !== 'public') {
|
||||
this.globalEventService.publishNoteStream(cascadingNote.id, 'updated', {
|
||||
cw: undefined,
|
||||
text: undefined,
|
||||
visibility: data.visibility,
|
||||
updatedAt: data.updatedAt,
|
||||
});
|
||||
|
||||
await this.notesRepository.update({
|
||||
id: cascadingNote.id,
|
||||
}, {
|
||||
updatedAt: updatedAt,
|
||||
visibility: data.visibility as any,
|
||||
});
|
||||
await this.notesRepository.update({
|
||||
id: cascadingNote.id,
|
||||
}, {
|
||||
visibility: data.visibility as any,
|
||||
updatedAt: data.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.notesRepository.update({
|
||||
id: note.id,
|
||||
userId: user.id,
|
||||
}, {
|
||||
updatedAt: updatedAt,
|
||||
visibility: data.visibility as any,
|
||||
});
|
||||
}, data as any);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -26,6 +26,7 @@ export type RolePolicies = {
|
|||
gtlAvailable: boolean;
|
||||
ltlAvailable: boolean;
|
||||
canPublicNote: boolean;
|
||||
canEditNote: boolean;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
inviteLimitCycle: number;
|
||||
|
@ -50,6 +51,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
gtlAvailable: true,
|
||||
ltlAvailable: true,
|
||||
canPublicNote: true,
|
||||
canEditNote: true,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
inviteLimitCycle: 60 * 24 * 7,
|
||||
|
@ -294,6 +296,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
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)),
|
||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
|
||||
|
|
|
@ -308,6 +308,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
|
||||
userId: note.userId,
|
||||
user: this.userEntityService.pack(note.user ?? note.userId, me, {
|
||||
detail: false,
|
||||
|
|
|
@ -245,7 +245,7 @@ export class MiNote {
|
|||
nullable: true,
|
||||
comment: 'The updated date of the Note.',
|
||||
})
|
||||
public updatedAt: Date;
|
||||
public updatedAt: Date | null;
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
if (data == null) return;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -6,12 +6,14 @@ import { NoteUpdateService } from '@/core/NoteUpdateService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
requireCredential: true,
|
||||
requireRolePolicy: 'canEditNote',
|
||||
|
||||
kind: 'write:notes',
|
||||
|
||||
|
@ -33,6 +35,12 @@ export const meta = {
|
|||
code: 'ACCESS_DENIED',
|
||||
id: 'fe8d7103-0ea8-4ec3-814d-f8b401dc69e9',
|
||||
},
|
||||
|
||||
invalidParam: {
|
||||
message: 'Invalid param.',
|
||||
code: 'INVALID_PARAM',
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
}
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -40,9 +48,20 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
noteId: { type: 'string', format: 'misskey:id' },
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: MAX_NOTE_TEXT_LENGTH,
|
||||
nullable: false,
|
||||
},
|
||||
cw: { type: 'string', nullable: true, maxLength: 100 },
|
||||
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] },
|
||||
},
|
||||
required: ['noteId', 'visibility'],
|
||||
required: ['noteId'],
|
||||
anyOf: [
|
||||
{ required: ['text'] },
|
||||
{ required: ['visibility'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
|
@ -62,13 +81,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
throw err;
|
||||
});
|
||||
|
||||
if (!await this.roleService.isModerator(me) && !await this.roleService.isAdministrator(me)) {
|
||||
if (ps.visibility !== note.visibility && !await this.roleService.isModerator(me) && !await this.roleService.isAdministrator(me)) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
if (ps.text === undefined && ps.visibility === undefined) {
|
||||
throw new ApiError(meta.errors.invalidParam);
|
||||
}
|
||||
|
||||
// この操作を行うのが投稿者とは限らない(例えばモデレーター)ため
|
||||
await this.noteUpdateService.update(await this.usersRepository.findOneByOrFail({ id: note.userId }), note, {
|
||||
cw: ps.cw,
|
||||
text: ps.text,
|
||||
visibility: ps.visibility,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -131,8 +131,10 @@ export interface NoteStreamTypes {
|
|||
deletedAt: Date;
|
||||
};
|
||||
updated: {
|
||||
cw?: string | null;
|
||||
text?: string;
|
||||
visibility?: string;
|
||||
updatedAt: Date;
|
||||
visibility: string;
|
||||
};
|
||||
reacted: {
|
||||
reaction: string;
|
||||
|
|
|
@ -93,6 +93,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<footer>
|
||||
<div :class="$style.noteFooterInfo">
|
||||
<div v-if="appearNote.updatedAt">
|
||||
{{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/>
|
||||
</div>
|
||||
<MkA :to="notePage(appearNote)">
|
||||
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
||||
</MkA>
|
||||
|
|
|
@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<img v-for="role in note.user.badgeRoles" :key="role.id" 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;" :title="i18n.ts.edited"><i class="ti ti-pencil"></i></span>
|
||||
<MkA :to="notePage(note)">
|
||||
<MkTime :time="note.createdAt"/>
|
||||
</MkA>
|
||||
|
|
|
@ -143,6 +143,7 @@ const props = withDefaults(defineProps<{
|
|||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
freezeAfterPosted?: boolean;
|
||||
updateMode?: boolean;
|
||||
}>(), {
|
||||
initialVisibleUsers: () => [],
|
||||
autofocus: true,
|
||||
|
@ -161,6 +162,7 @@ const visibilityButton = $shallowRef<HTMLElement | null>(null);
|
|||
|
||||
let posting = $ref(false);
|
||||
let posted = $ref(false);
|
||||
let noteId = $ref(props.initialNote ? props.initialNote.id : null);
|
||||
let text = $ref(props.initialText ?? '');
|
||||
let files = $ref(props.initialFiles ?? []);
|
||||
let poll = $ref<{
|
||||
|
@ -698,13 +700,14 @@ async function post(ev?: MouseEvent) {
|
|||
}
|
||||
|
||||
let postData = {
|
||||
text: text === '' ? undefined : text,
|
||||
noteId: noteId,
|
||||
text: text === '' ? null : text,
|
||||
fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
|
||||
replyId: props.reply ? props.reply.id : undefined,
|
||||
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
|
||||
channelId: props.channel ? props.channel.id : undefined,
|
||||
poll: poll,
|
||||
cw: useCw ? cw ?? '' : undefined,
|
||||
cw: useCw ? cw ?? '' : null,
|
||||
localOnly: localOnly,
|
||||
visibility: visibility,
|
||||
visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
|
||||
|
@ -731,7 +734,7 @@ async function post(ev?: MouseEvent) {
|
|||
}
|
||||
|
||||
posting = true;
|
||||
os.api('notes/create', postData, token).then(() => {
|
||||
os.api(props.updateMode ? 'notes/update' : 'notes/create', postData, token).then(() => {
|
||||
if (props.freezeAfterPosted) {
|
||||
posted = true;
|
||||
} else {
|
||||
|
@ -877,6 +880,7 @@ onMounted(() => {
|
|||
// 削除して編集
|
||||
if (props.initialNote) {
|
||||
const init = props.initialNote;
|
||||
noteId = init.id;
|
||||
text = init.text ? init.text : '';
|
||||
files = init.files;
|
||||
cw = init.cw;
|
||||
|
|
|
@ -30,6 +30,7 @@ const props = defineProps<{
|
|||
instant?: boolean;
|
||||
fixed?: boolean;
|
||||
autofocus?: boolean;
|
||||
updateMode?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
|
|||
'gtlAvailable',
|
||||
'ltlAvailable',
|
||||
'canPublicNote',
|
||||
'canEditNote',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
'inviteLimitCycle',
|
||||
|
|
|
@ -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.canInvite, 'canInvite'])">
|
||||
<template #label>{{ i18n.ts._role._options.canInvite }}</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.canInvite, 'canInvite'])">
|
||||
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
||||
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
|
|
|
@ -186,6 +186,10 @@ export function getNoteMenu(props: {
|
|||
});
|
||||
}
|
||||
|
||||
function edit(): void {
|
||||
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel, updateMode: true });
|
||||
}
|
||||
|
||||
function toggleFavorite(favorite: boolean): void {
|
||||
claimAchievement('noteFavorited1');
|
||||
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
|
||||
|
@ -392,6 +396,11 @@ export function getNoteMenu(props: {
|
|||
),
|
||||
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
|
||||
null,
|
||||
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,
|
||||
|
|
|
@ -78,9 +78,19 @@ export function useNoteCapture(props: {
|
|||
}
|
||||
|
||||
case 'updated': {
|
||||
note.value.visibility = body.visibility;
|
||||
if ((defaultStore.reactiveState.tl.value.src === 'local' || defaultStore.reactiveState.tl.value.src === 'global') && note.value.visibility !== 'public') {
|
||||
props.isDeletedRef.value = true;
|
||||
note.value.updatedAt = body.updatedAt;
|
||||
|
||||
if (body.text !== undefined) {
|
||||
note.value.cw = body.cw;
|
||||
note.value.text = body.text;
|
||||
}
|
||||
|
||||
if (body.visibility !== undefined) {
|
||||
note.value.visibility = body.visibility;
|
||||
|
||||
if ((defaultStore.reactiveState.tl.value.src === 'local' || defaultStore.reactiveState.tl.value.src === 'global') && note.value.visibility !== 'public') {
|
||||
props.isDeletedRef.value = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -2616,6 +2616,7 @@ export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"];
|
|||
type Note = {
|
||||
id: ID;
|
||||
createdAt: DateString;
|
||||
updatedAt: DateString | null;
|
||||
text: string | null;
|
||||
cw: string | null;
|
||||
user: User;
|
||||
|
@ -2954,7 +2955,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
|
|||
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
|
||||
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
||||
// src/api.types.ts:631:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
||||
// src/entities.ts:579:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
// src/entities.ts:580:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
|
|
@ -162,6 +162,7 @@ export type GalleryPost = {
|
|||
export type Note = {
|
||||
id: ID;
|
||||
createdAt: DateString;
|
||||
updatedAt?: DateString | null;
|
||||
text: string | null;
|
||||
cw: string | null;
|
||||
user: User;
|
||||
|
|
|
@ -137,8 +137,10 @@ export type NoteUpdatedEvent = {
|
|||
id: Note['id'];
|
||||
type: 'updated';
|
||||
body: {
|
||||
updatedAt: string;
|
||||
visibility: string;
|
||||
cw?: string | null;
|
||||
text?: string;
|
||||
visibility?: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
} | {
|
||||
id: Note['id'];
|
||||
|
|
Loading…
Reference in a new issue