feat: editing note content (local)

This commit is contained in:
ZerglingGo 2023-10-08 00:00:03 +09:00
parent 640c579dcd
commit 6d834d40b1
No known key found for this signature in database
GPG key ID: 3919613C1147B4BF
22 changed files with 139 additions and 35 deletions

2
locales/index.d.ts vendored
View file

@ -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;

View file

@ -1125,6 +1125,7 @@ unnotifyNotes: "投稿の通知を解除"
authentication: "認証"
authenticationRequiredToContinue: "続けるには認証を行ってください"
dateAndTime: "日時"
edited: "編集済み"
_announcement:
forExistingUsers: "既存ユーザーのみ"
@ -1462,6 +1463,7 @@ _role:
gtlAvailable: "グローバルタイムラインの閲覧"
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
canEditNote: "ノートの編集"
canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔"

View file

@ -1111,6 +1111,7 @@ youHaveUnreadAnnouncements: "읽지 않은 공지사항이 있습니다."
useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 주십시오."
replies: "답글"
renotes: "리노트"
edited: "편집됨"
_announcement:
forExistingUsers: "기존 유저에게만 알림"
forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다."
@ -1431,6 +1432,7 @@ _role:
gtlAvailable: "글로벌 타임라인 보이기"
ltlAvailable: "로컬 타임라인 보이기"
canPublicNote: "공개 노트 허용"
canEditNote: "노트 편집 허용"
canInvite: "서버 초대 코드 발행"
inviteLimit: "초대 한도"
inviteLimitCycle: "초대 발급 간격"

View file

@ -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

View file

@ -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)),

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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(),
});
});
}

View file

@ -131,8 +131,10 @@ export interface NoteStreamTypes {
deletedAt: Date;
};
updated: {
cw?: string | null;
text?: string;
visibility?: string;
updatedAt: Date;
visibility: string;
};
reacted: {
reaction: string;

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -30,6 +30,7 @@ const props = defineProps<{
instant?: boolean;
fixed?: boolean;
autofocus?: boolean;
updateMode?: boolean;
}>();
const emit = defineEmits<{

View file

@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'canEditNote',
'canInvite',
'inviteLimit',
'inviteLimitCycle',

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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;
}

View file

@ -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)

View file

@ -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;

View file

@ -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'];