From eb740e2c72ae6854b244ad099c927c069008720e Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 28 Sep 2023 11:41:41 +0900 Subject: enhance: タイムラインからRenoteを除外するオプションを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/api/endpoints/notes/user-list-timeline.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 6932073791..c20274b2ba 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -49,6 +49,8 @@ export const paramDef = { includeMyRenotes: { type: 'boolean', default: true }, includeRenotedMyNotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true }, + withReplies: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false, @@ -130,6 +132,20 @@ export default class extends Endpoint { // eslint- })); } + if (!ps.withReplies) { + query.andWhere('note.replyId IS NULL'); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } -- cgit v1.2.3-freya From 772d2432b6e84a7a7c0fa8ad1852701cdc600f88 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 28 Sep 2023 15:32:47 +0900 Subject: enhance: タイムラインからRenoteを除外するオプションを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/api/endpoints/notes/global-timeline.ts | 12 ++++++++ .../server/api/endpoints/notes/hybrid-timeline.ts | 11 ++++++++ .../server/api/endpoints/notes/local-timeline.ts | 11 ++++++++ .../src/server/api/endpoints/notes/timeline.ts | 11 ++++++++ packages/frontend/src/ui/deck/deck-store.ts | 2 ++ packages/frontend/src/ui/deck/tl-column.vue | 33 ++++++++++++++++++++-- 6 files changed, 78 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 0b3b5c902e..8784e86153 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -40,6 +41,7 @@ export const paramDef = { properties: { withFiles: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -88,6 +90,16 @@ export default class extends Endpoint { // eslint- if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } //#endregion const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index e9ae5dc755..9bde5dee21 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -52,6 +52,7 @@ export const paramDef = { includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, }, required: [], } as const; @@ -137,6 +138,16 @@ export default class extends Endpoint { // eslint- if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } //#endregion const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index af1e0398dc..0fefddc51b 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -42,6 +42,7 @@ export const paramDef = { properties: { withFiles: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, fileType: { type: 'array', items: { type: 'string', } }, @@ -110,6 +111,16 @@ export default class extends Endpoint { // eslint- query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); } } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } //#endregion const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 042115ab84..0d47cc1702 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -42,6 +42,7 @@ export const paramDef = { includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, }, required: [], } as const; @@ -126,6 +127,16 @@ export default class extends Endpoint { // eslint- if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } //#endregion const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index f910c5181d..034d675b0e 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -30,6 +30,8 @@ export type Column = { roleId?: string; includingTypes?: typeof notificationTypes[number][]; tl?: 'home' | 'local' | 'social' | 'global'; + withRenotes?: boolean; + withReplies?: boolean; }; export const deckStore = markRaw(new Storage('deck', { diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 813b801d21..073898409c 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -20,12 +20,19 @@ SPDX-License-Identifier: AGPL-3.0-only

{{ i18n.ts._disabledTimeline.description }}

- + -- cgit v1.2.3-freya From c106db89e1d54c20c6466e42dde540e0d5c5c4eb Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 28 Sep 2023 17:21:16 +0900 Subject: feat: note edit --- CHANGELOG.md | 2 + locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + packages/backend/src/core/RoleService.ts | 3 + packages/backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../src/server/api/endpoints/notes/update.ts | 88 ++++++++++++++++++++++ packages/backend/src/server/api/stream/types.ts | 4 + packages/frontend/src/components/MkPostForm.vue | 8 +- .../frontend/src/components/MkPostFormDialog.vue | 1 + packages/frontend/src/const.ts | 1 + packages/frontend/src/pages/admin/roles.editor.vue | 20 +++++ packages/frontend/src/pages/admin/roles.vue | 8 ++ packages/frontend/src/scripts/get-note-menu.ts | 9 +++ packages/frontend/src/scripts/use-note-capture.ts | 6 ++ packages/misskey-js/src/streaming.types.ts | 7 ++ 16 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/notes/update.ts (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/CHANGELOG.md b/CHANGELOG.md index c711572655..d24364c57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ ## next ### General +- Feat: ノートの編集をできるように + - ロールで編集可否を設定可能 - Enhance: タイムラインからRenoteを除外するオプションを追加 - Enhance: ユーザーページのノート一覧でRenoteを除外できるように diff --git a/locales/index.d.ts b/locales/index.d.ts index eb2793c710..8c6b724623 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1538,6 +1538,7 @@ export interface Locale { "gtlAvailable": string; "ltlAvailable": string; "canPublicNote": string; + "canEditNote": string; "canInvite": string; "inviteLimit": string; "inviteLimitCycle": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 637d580d6a..c31b4a5c27 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1459,6 +1459,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canEditNote: "ノートの編集" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" inviteLimitCycle: "招待コードの発行間隔" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 934b7d676b..ec4d804219 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -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)), diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 41a11bfb19..c883c96ba2 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -258,6 +258,7 @@ 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_delete from './endpoints/notes/delete.js'; +import * as ep___notes_update from './endpoints/notes/update.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'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -606,6 +607,7 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes 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_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; +const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.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 }; const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; @@ -958,6 +960,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_conversation, $notes_create, $notes_delete, + $notes_update, $notes_favorites_create, $notes_favorites_delete, $notes_featured, @@ -1304,6 +1307,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_conversation, $notes_create, $notes_delete, + $notes_update, $notes_favorites_create, $notes_favorites_delete, $notes_featured, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index ab20a708ef..b40d654f9c 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -258,6 +258,7 @@ 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_delete from './endpoints/notes/delete.js'; +import * as ep___notes_update from './endpoints/notes/update.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'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -604,6 +605,7 @@ const eps = [ ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], ['notes/delete', ep___notes_delete], + ['notes/update', ep___notes_update], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts new file mode 100644 index 0000000000..ccd2878d3c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository, NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.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', + + 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', + }, + }, +} 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: false, + }, + cw: { type: 'string', nullable: true, maxLength: 100 }, + }, + required: ['noteId', 'text', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private getterService: GetterService, + private globalEventService: GlobalEventService, + ) { + super(meta, 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); + } + + await this.notesRepository.update({ id: note.id }, { + cw: ps.cw, + text: ps.text, + }); + + this.globalEventService.publishNoteStream(note.id, 'updated', { + cw: ps.cw, + text: ps.text, + }); + }); + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 90e0a61f26..2436750cd6 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -130,6 +130,10 @@ export interface NoteStreamTypes { deleted: { deletedAt: Date; }; + updated: { + cw: string | null; + text: string; + }; reacted: { reaction: string; emoji?: { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 1f4f75d5ed..b82ca3ef19 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -143,6 +143,7 @@ const props = withDefaults(defineProps<{ fixed?: boolean; autofocus?: boolean; freezeAfterPosted?: boolean; + updateMode?: boolean; }>(), { initialVisibleUsers: () => [], autofocus: true, @@ -698,17 +699,18 @@ async function post(ev?: MouseEvent) { } let postData = { - text: text === '' ? undefined : text, + 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, reactionAcceptance, + noteId: props.updateMode ? props.initialNote?.id : undefined, }; if (withHashtags && hashtags && hashtags.trim() !== '') { @@ -731,7 +733,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 { diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index c07a166a83..f33d498f93 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -30,6 +30,7 @@ const props = defineProps<{ instant?: boolean; fixed?: boolean; autofocus?: boolean; + updateMode?: boolean; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 15038b1063..9fd6d40d72 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -61,6 +61,7 @@ export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', 'canPublicNote', + 'canEditNote', 'canInvite', 'inviteLimit', 'inviteLimitCycle', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 2ef3e254cd..1b72e1d332 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+