From dd6569a1bb025f2e295c9d19d870febcde712ea1 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Wed, 8 Mar 2023 08:56:47 +0900 Subject: [PATCH] feat: Reaction acceptance (#10256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * デフォルト設定 --- CHANGELOG.md | 3 +- locales/ja-JP.yml | 3 ++ ...8164627293-per-note-reaction-acceptance.js | 11 +++++++ .../backend/src/core/NoteCreateService.ts | 2 ++ packages/backend/src/core/ReactionService.ts | 8 +++-- .../src/core/entities/NoteEntityService.ts | 1 + packages/backend/src/misc/schema.ts | 4 +-- packages/backend/src/models/entities/Note.ts | 5 +++ packages/backend/src/models/schema/note.ts | 4 +++ .../ExportFavoritesProcessorService.ts | 1 + .../processors/ExportNotesProcessorService.ts | 3 +- .../src/server/api/endpoints/notes/create.ts | 4 ++- packages/frontend/src/components/MkNote.vue | 33 ++++++++++++++----- .../src/components/MkNoteDetailed.vue | 33 ++++++++++++++----- .../frontend/src/components/MkPostForm.vue | 27 ++++++++++++++- .../frontend/src/pages/settings/profile.vue | 12 ++++++- packages/frontend/src/store.ts | 4 +++ 17 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 packages/backend/migration/1678164627293-per-note-reaction-acceptance.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dbc2bb1f..923e8680f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,11 @@ You should also include the user name that made the change. ### Improvements - ユーザーごとにRenoteをミュートできるように +- ノートごとに絵文字リアクションを受け取るか設定できるように - enhance(client): DM作成時にメンションも含むように ### Bugfixes -- +- ## 13.9.2 (2023/03/06) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 47e2af713b..750da3e98a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -961,6 +961,9 @@ invitationRequiredToRegister: "現在このサーバーは招待制です。招 emailNotSupported: "このサーバーではメール配信はサポートされていません" postToTheChannel: "チャンネルに投稿" cannotBeChangedLater: "後から変更できません。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねのみ" +likeOnlyForRemote: "リモートからはいいねのみ" _achievements: earnedAt: "獲得日時" diff --git a/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js new file mode 100644 index 0000000000..f1765dd146 --- /dev/null +++ b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js @@ -0,0 +1,11 @@ +export class perNoteReactionAcceptance1678164627293 { + name = 'perNoteReactionAcceptance1678164627293' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "reactionAcceptance" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAcceptance"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4c4261ba79..8d8535ca5b 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -125,6 +125,7 @@ type Option = { files?: DriveFile[] | null; poll?: IPoll | null; localOnly?: boolean | null; + reactionAcceptance?: Note['reactionAcceptance']; cw?: string | null; visibility?: string; visibleUsers?: MinimumUser[] | null; @@ -346,6 +347,7 @@ export class NoteCreateService implements OnApplicationShutdown { emojis, userId: user.id, localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, visibility: data.visibility as any, visibleUserIds: data.visibility === 'specified' ? data.visibleUsers diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 9fccc14ee4..3e644018d7 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -99,8 +99,12 @@ export class ReactionService { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } - // TODO: cache - reaction = await this.toDbReaction(reaction, user.host); + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { + reaction = '❤️'; + } else { + // TODO: cache + reaction = await this.toDbReaction(reaction, user.host); + } const record: NoteReaction = { id: this.idService.genId(), diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 4ec10df9a6..67850ad9aa 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -314,6 +314,7 @@ export class NoteEntityService implements OnModuleInit { cw: note.cw, visibility: note.visibility, localOnly: note.localOnly ?? undefined, + reactionAcceptance: note.reactionAcceptance, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 0681cdb67e..9b8af6958f 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -95,7 +95,7 @@ export interface Schema extends OfSchema { readonly example?: any; readonly format?: string; readonly ref?: keyof typeof refs; - readonly enum?: ReadonlyArray<string>; + readonly enum?: ReadonlyArray<string | null>; readonly default?: (this['type'] extends TypeStringef ? StringDefToType<this['type']> : any) | null; readonly maxLength?: number; readonly minLength?: number; @@ -161,7 +161,7 @@ export type SchemaTypeDef<p extends Schema> = p['type'] extends 'integer' ? number : p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( - p['enum'] extends readonly string[] ? + p['enum'] extends readonly (string | null)[] ? p['enum'][number] : p['format'] extends 'date-time' ? string : // Dateにする?? string diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 82d042f0ce..df508b4dca 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -87,6 +87,11 @@ export class Note { }) public localOnly: boolean; + @Column('varchar', { + length: 64, nullable: true, + }) + public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; + @Column('smallint', { default: 0, }) diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts index 72c0c62285..58ef425dcd 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/schema/note.ts @@ -141,6 +141,10 @@ export const packedNoteSchema = { type: 'boolean', optional: true, nullable: false, }, + reactionAcceptance: { + type: 'string', + optional: false, nullable: true, + }, reactions: { type: 'object', optional: false, nullable: false, diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index c65f0a97a0..e9330772b9 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol visibility: favorite.note.visibility, visibleUserIds: favorite.note.visibleUserIds, localOnly: favorite.note.localOnly, + reactionAcceptance: favorite.note.reactionAcceptance, uri: favorite.note.uri, url: favorite.note.url, user: { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 3f4f16a2ec..2f74dd63cc 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; import type { DbUserJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportNotesProcessorService { @@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record<string, unknown visibility: note.visibility, visibleUserIds: note.visibleUserIds, localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, }; } diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 786ad103b0..69fafcb9c7 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -97,6 +97,7 @@ export const paramDef = { } }, cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, @@ -110,7 +111,7 @@ export const paramDef = { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, - nullable: false + nullable: false, }, fileIds: { type: 'array', @@ -280,6 +281,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { renote, cw: ps.cw, localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, visibleUsers, channel, diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index bb1269562d..af81051a54 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -103,7 +103,8 @@ <i class="ti ti-ban"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> - <i class="ti ti-plus"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> </button> <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> @@ -329,18 +330,32 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); - blur(); - reactionPicker.show(reactButton.value, reaction => { + if (appearNote.reactionAcceptance === 'likeOnly') { os.api('notes/reactions/create', { noteId: appearNote.id, - reaction: reaction, + reaction: '❤️', }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { - claimAchievement('reactWithoutRead'); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - }, () => { - focus(); - }); + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } } function undoReact(note): void { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index f5f4a2afc1..ea72e1b517 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -108,7 +108,8 @@ <i class="ti ti-ban"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()"> - <i class="ti ti-plus"></i> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> </button> <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <i class="ti ti-minus"></i> @@ -323,18 +324,32 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); - blur(); - reactionPicker.show(reactButton.value, reaction => { + if (appearNote.reactionAcceptance === 'likeOnly') { os.api('notes/reactions/create', { noteId: appearNote.id, - reaction: reaction, + reaction: '❤️', }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { - claimAchievement('reactWithoutRead'); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - }, () => { - focus(); - }); + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } } function undoReact(note): void { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index f610569f6d..ee5a9e1810 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -53,14 +53,23 @@ <XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <XNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> + <div v-if="showingOptions" style="padding: 0 16px;"> + <MkSelect v-model="reactionAcceptance" small> + <template #label>{{ i18n.ts.reactionAcceptance }}</template> + <option :value="null">{{ i18n.ts.all }}</option> + <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> + <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> + </MkSelect> + </div> + <button v-tooltip="i18n.ts.emoji" class="_button" :class="$style.emojiButton" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <footer :class="$style.footer"> <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> <button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> - <button v-tooltip="i18n.ts.emoji" class="_button" :class="$style.footerButton" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> + <button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button> </footer> <datalist id="hashtags"> <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> @@ -76,6 +85,7 @@ import * as misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode/'; import * as Acct from 'misskey-js/built/acct'; +import MkSelect from './MkSelect.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import XNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; @@ -151,12 +161,14 @@ let visibleUsers = $ref([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(pushVisibleUser); } +let reactionAcceptance = $ref(defaultStore.state.reactionAcceptance); let autocomplete = $ref(null); let draghover = $ref(false); let quoteId = $ref(null); let hasNotSpecifiedMentions = $ref(false); let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]')); let imeText = $ref(''); +let showingOptions = $ref(false); const draftKey = $computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -614,6 +626,7 @@ async function post(ev?: MouseEvent) { localOnly: localOnly, visibility: visibility, visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined, + reactionAcceptance, }; if (withHashtags && hashtags && hashtags.trim() !== '') { @@ -1030,6 +1043,18 @@ defineExpose({ } } +.emojiButton { + position: absolute; + top: 55px; + right: 13px; + display: inline-block; + padding: 0; + margin: 0; + font-size: 1em; + width: 32px; + height: 32px; +} + @container (max-width: 500px) { .header { height: 50px; diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 41563c441f..4776a87d5b 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -64,12 +64,19 @@ </div> </MkFolder> + <MkSelect v-model="reactionAcceptance"> + <template #label>{{ i18n.ts.reactionAcceptance }}</template> + <option :value="null">{{ i18n.ts.all }}</option> + <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> + <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> + </MkSelect> + <MkSwitch v-model="profile.showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch> </div> </template> <script lang="ts" setup> -import { reactive, watch } from 'vue'; +import { computed, reactive, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -85,6 +92,9 @@ import { $i } from '@/account'; import { langmap } from '@/scripts/langmap'; import { definePageMetadata } from '@/scripts/page-metadata'; import { claimAchievement } from '@/scripts/achievements'; +import { defaultStore } from '@/store'; + +const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); const profile = reactive({ name: $i.name, diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index d84c056538..a68386fa4f 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -81,6 +81,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }, + reactionAcceptance: { + where: 'account', + default: null, + }, mutedWords: { where: 'account', default: [],