feat: Reaction acceptance (#10256)

* wip

* wip

* デフォルト設定
This commit is contained in:
syuilo 2023-03-08 08:56:47 +09:00 committed by GitHub
parent 4c2f7c64cc
commit dd6569a1bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 131 additions and 27 deletions

View file

@ -14,6 +14,7 @@ You should also include the user name that made the change.
### Improvements ### Improvements
- ユーザーごとにRenoteをミュートできるように - ユーザーごとにRenoteをミュートできるように
- ノートごとに絵文字リアクションを受け取るか設定できるように
- enhance(client): DM作成時にメンションも含むように - enhance(client): DM作成時にメンションも含むように
### Bugfixes ### Bugfixes

View file

@ -961,6 +961,9 @@ invitationRequiredToRegister: "現在このサーバーは招待制です。招
emailNotSupported: "このサーバーではメール配信はサポートされていません" emailNotSupported: "このサーバーではメール配信はサポートされていません"
postToTheChannel: "チャンネルに投稿" postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後から変更できません。" cannotBeChangedLater: "後から変更できません。"
reactionAcceptance: "リアクションの受け入れ"
likeOnly: "いいねのみ"
likeOnlyForRemote: "リモートからはいいねのみ"
_achievements: _achievements:
earnedAt: "獲得日時" earnedAt: "獲得日時"

View file

@ -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"`);
}
}

View file

@ -125,6 +125,7 @@ type Option = {
files?: DriveFile[] | null; files?: DriveFile[] | null;
poll?: IPoll | null; poll?: IPoll | null;
localOnly?: boolean | null; localOnly?: boolean | null;
reactionAcceptance?: Note['reactionAcceptance'];
cw?: string | null; cw?: string | null;
visibility?: string; visibility?: string;
visibleUsers?: MinimumUser[] | null; visibleUsers?: MinimumUser[] | null;
@ -346,6 +347,7 @@ export class NoteCreateService implements OnApplicationShutdown {
emojis, emojis,
userId: user.id, userId: user.id,
localOnly: data.localOnly!, localOnly: data.localOnly!,
reactionAcceptance: data.reactionAcceptance,
visibility: data.visibility as any, visibility: data.visibility as any,
visibleUserIds: data.visibility === 'specified' visibleUserIds: data.visibility === 'specified'
? data.visibleUsers ? data.visibleUsers

View file

@ -99,8 +99,12 @@ export class ReactionService {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
} }
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) {
reaction = '❤️';
} else {
// TODO: cache // TODO: cache
reaction = await this.toDbReaction(reaction, user.host); reaction = await this.toDbReaction(reaction, user.host);
}
const record: NoteReaction = { const record: NoteReaction = {
id: this.idService.genId(), id: this.idService.genId(),

View file

@ -314,6 +314,7 @@ export class NoteEntityService implements OnModuleInit {
cw: note.cw, cw: note.cw,
visibility: note.visibility, visibility: note.visibility,
localOnly: note.localOnly ?? undefined, localOnly: note.localOnly ?? undefined,
reactionAcceptance: note.reactionAcceptance,
visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined,
renoteCount: note.renoteCount, renoteCount: note.renoteCount,
repliesCount: note.repliesCount, repliesCount: note.repliesCount,

View file

@ -95,7 +95,7 @@ export interface Schema extends OfSchema {
readonly example?: any; readonly example?: any;
readonly format?: string; readonly format?: string;
readonly ref?: keyof typeof refs; 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 default?: (this['type'] extends TypeStringef ? StringDefToType<this['type']> : any) | null;
readonly maxLength?: number; readonly maxLength?: number;
readonly minLength?: number; readonly minLength?: number;
@ -161,7 +161,7 @@ export type SchemaTypeDef<p extends Schema> =
p['type'] extends 'integer' ? number : p['type'] extends 'integer' ? number :
p['type'] extends 'number' ? number : p['type'] extends 'number' ? number :
p['type'] extends 'string' ? ( p['type'] extends 'string' ? (
p['enum'] extends readonly string[] ? p['enum'] extends readonly (string | null)[] ?
p['enum'][number] : p['enum'][number] :
p['format'] extends 'date-time' ? string : // Dateにする p['format'] extends 'date-time' ? string : // Dateにする
string string

View file

@ -87,6 +87,11 @@ export class Note {
}) })
public localOnly: boolean; public localOnly: boolean;
@Column('varchar', {
length: 64, nullable: true,
})
public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null;
@Column('smallint', { @Column('smallint', {
default: 0, default: 0,
}) })

View file

@ -141,6 +141,10 @@ export const packedNoteSchema = {
type: 'boolean', type: 'boolean',
optional: true, nullable: false, optional: true, nullable: false,
}, },
reactionAcceptance: {
type: 'string',
optional: false, nullable: true,
},
reactions: { reactions: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol
visibility: favorite.note.visibility, visibility: favorite.note.visibility,
visibleUserIds: favorite.note.visibleUserIds, visibleUserIds: favorite.note.visibleUserIds,
localOnly: favorite.note.localOnly, localOnly: favorite.note.localOnly,
reactionAcceptance: favorite.note.reactionAcceptance,
uri: favorite.note.uri, uri: favorite.note.uri,
url: favorite.note.url, url: favorite.note.url,
user: { user: {

View file

@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import type { Poll } from '@/models/entities/Poll.js'; import type { Poll } from '@/models/entities/Poll.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbUserJobData } from '../types.js';
import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
export class ExportNotesProcessorService { export class ExportNotesProcessorService {
@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record<string, unknown
visibility: note.visibility, visibility: note.visibility,
visibleUserIds: note.visibleUserIds, visibleUserIds: note.visibleUserIds,
localOnly: note.localOnly, localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
}; };
} }

View file

@ -97,6 +97,7 @@ export const paramDef = {
} }, } },
cw: { type: 'string', nullable: true, maxLength: 100 }, cw: { type: 'string', nullable: true, maxLength: 100 },
localOnly: { type: 'boolean', default: false }, localOnly: { type: 'boolean', default: false },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false }, noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false },
@ -110,7 +111,7 @@ export const paramDef = {
type: 'string', type: 'string',
minLength: 1, minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH, maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false nullable: false,
}, },
fileIds: { fileIds: {
type: 'array', type: 'array',
@ -280,6 +281,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
renote, renote,
cw: ps.cw, cw: ps.cw,
localOnly: ps.localOnly, localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility, visibility: ps.visibility,
visibleUsers, visibleUsers,
channel, channel,

View file

@ -103,7 +103,8 @@
<i class="ti ti-ban"></i> <i class="ti ti-ban"></i>
</button> </button>
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> <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>
<button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)"> <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i> <i class="ti ti-minus"></i>
@ -329,6 +330,19 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
if (appearNote.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
});
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');
}
} else {
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', { os.api('notes/reactions/create', {
@ -341,6 +355,7 @@ function react(viaKeyboard = false): void {
}, () => { }, () => {
focus(); focus();
}); });
}
} }
function undoReact(note): void { function undoReact(note): void {

View file

@ -108,7 +108,8 @@
<i class="ti ti-ban"></i> <i class="ti ti-ban"></i>
</button> </button>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()"> <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>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i> <i class="ti ti-minus"></i>
@ -323,6 +324,19 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
if (appearNote.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: '❤️',
});
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');
}
} else {
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', { os.api('notes/reactions/create', {
@ -335,6 +349,7 @@ function react(viaKeyboard = false): void {
}, () => { }, () => {
focus(); focus();
}); });
}
} }
function undoReact(note): void { function undoReact(note): void {

View file

@ -53,14 +53,23 @@
<XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> <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"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<XNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> <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"> <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.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.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.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.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.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-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> </footer>
<datalist id="hashtags"> <datalist id="hashtags">
<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> <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 insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode/'; import { toASCII } from 'punycode/';
import * as Acct from 'misskey-js/built/acct'; import * as Acct from 'misskey-js/built/acct';
import MkSelect from './MkSelect.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
import XNotePreview from '@/components/MkNotePreview.vue'; import XNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
@ -151,12 +161,14 @@ let visibleUsers = $ref([]);
if (props.initialVisibleUsers) { if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(pushVisibleUser); props.initialVisibleUsers.forEach(pushVisibleUser);
} }
let reactionAcceptance = $ref(defaultStore.state.reactionAcceptance);
let autocomplete = $ref(null); let autocomplete = $ref(null);
let draghover = $ref(false); let draghover = $ref(false);
let quoteId = $ref(null); let quoteId = $ref(null);
let hasNotSpecifiedMentions = $ref(false); let hasNotSpecifiedMentions = $ref(false);
let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]')); let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'));
let imeText = $ref(''); let imeText = $ref('');
let showingOptions = $ref(false);
const draftKey = $computed((): string => { const draftKey = $computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : ''; let key = props.channel ? `channel:${props.channel.id}` : '';
@ -614,6 +626,7 @@ async function post(ev?: MouseEvent) {
localOnly: localOnly, localOnly: localOnly,
visibility: visibility, visibility: visibility,
visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined, visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
reactionAcceptance,
}; };
if (withHashtags && hashtags && hashtags.trim() !== '') { 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) { @container (max-width: 500px) {
.header { .header {
height: 50px; height: 50px;

View file

@ -64,12 +64,19 @@
</div> </div>
</MkFolder> </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> <MkSwitch v-model="profile.showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
@ -85,6 +92,9 @@ import { $i } from '@/account';
import { langmap } from '@/scripts/langmap'; import { langmap } from '@/scripts/langmap';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { defaultStore } from '@/store';
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
const profile = reactive({ const profile = reactive({
name: $i.name, name: $i.name,

View file

@ -81,6 +81,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
}, },
reactionAcceptance: {
where: 'account',
default: null,
},
mutedWords: { mutedWords: {
where: 'account', where: 'account',
default: [], default: [],