feat(frontend): 絵文字ピッカーの実装 (#12617)

* 絵文字デッキの作成

* 細かい不備を修正

* fix lint

* fix

* fix CHANGELOG.md

* fix setTimeout -> nextTick

* fix https://github.com/misskey-dev/misskey/pull/12617#issuecomment-1848952862

* fix bug

* fix CHANGELOG.md

* fix CHANGELOG.md

* wip

* Update CHANGELOG.md

* Update CHANGELOG.md

* wip

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
おさむのひと 2023-12-14 14:11:20 +09:00 committed by GitHub
parent 364efbe58b
commit a92795d90f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 354 additions and 198 deletions

View file

@ -15,6 +15,17 @@
## 2023.x.x (unreleased) ## 2023.x.x (unreleased)
### Note
- 絵文字ピッカーにピン留め表示する絵文字設定が「リアクション用」と「絵文字入力用」に分かれました。以前の設定は「リアクション用」として使用されます。
**影響:**
それにより、投稿フォームから表示される絵文字ピッカーのピン留め絵文字がリセットされたように感じるかもしれません(新設された投稿用のピン留め絵文字が使われるため)。
投稿用のピン留め絵文字をアップデート前の状態にするには、以下の手順で操作します。
1. 「設定」メニューに移動し、「絵文字ピッカー」タブを選択します。
2. 「ピン留 (全般)」のタブを選択します。
3. 「リアクション設定からコピーする」ボタンを押すことで、アップデート前の状態に戻すことができます。
### General ### General
- Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) - Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed)
- Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83) - Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83)
@ -25,7 +36,8 @@
### Client ### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 - Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
- Feat: データセーバーでコードハイライトの読み込みを削減できるように - Feat: データセーバーでコードハイライトの読み込みを削減できるように
- Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336 - Enhance: 投稿フォームの絵文字ピッカーをリアクション時に使用するものと同じのを使用するように #12336 #12560
- Enhance: リアクション用ピン留め絵文字と投稿時の絵文字入力用ピン留め絵文字を分けて設定できるように #12560
- Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活 - Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように - Enhance: リアクション選択時に音を鳴らせるように

7
locales/index.d.ts vendored
View file

@ -124,7 +124,12 @@ export interface Locale {
"add": string; "add": string;
"reaction": string; "reaction": string;
"reactions": string; "reactions": string;
"reactionSetting": string; "emojiPicker": string;
"pinnedEmojisForReactionSettingDescription": string;
"pinnedEmojisSettingDescription": string;
"emojiPickerDisplay": string;
"copyFromPinnedEmojisForReaction": string;
"copyFromPinnedEmojis": string;
"reactionSettingDescription2": string; "reactionSettingDescription2": string;
"rememberNoteVisibility": string; "rememberNoteVisibility": string;
"attachCancel": string; "attachCancel": string;

View file

@ -121,7 +121,12 @@ sensitive: "センシティブ"
add: "追加" add: "追加"
reaction: "リアクション" reaction: "リアクション"
reactions: "リアクション" reactions: "リアクション"
reactionSetting: "ピッカーに表示するリアクション" emojiPicker: "絵文字ピッカー"
pinnedEmojisForReactionSettingDescription: "リアクション時にピン留め表示する絵文字を設定できます"
pinnedEmojisSettingDescription: "絵文字入力時にピン留め表示する絵文字を設定できます"
emojiPickerDisplay: "ピッカーの表示"
copyFromPinnedEmojisForReaction: "リアクション設定からコピーする"
copyFromPinnedEmojis: "絵文字設定からコピーする"
reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。" reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。"
rememberNoteVisibility: "公開範囲を記憶する" rememberNoteVisibility: "公開範囲を記憶する"
attachCancel: "添付取り消し" attachCancel: "添付取り消し"

View file

@ -121,10 +121,11 @@ import { $i } from '@/account.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
showPinned?: boolean; showPinned?: boolean;
asReactionPicker?: boolean; pinnedEmojis?: string[];
maxHeight?: number; maxHeight?: number;
asDrawer?: boolean; asDrawer?: boolean;
asWindow?: boolean; asWindow?: boolean;
asReactionPicker?: boolean; // 使使
}>(), { }>(), {
showPinned: true, showPinned: true,
}); });
@ -137,24 +138,22 @@ const searchEl = shallowRef<HTMLInputElement>();
const emojisEl = shallowRef<HTMLDivElement>(); const emojisEl = shallowRef<HTMLDivElement>();
const { const {
reactions: pinnedReactions, emojiPickerScale,
reactionPickerSize, emojiPickerWidth,
reactionPickerWidth, emojiPickerHeight,
reactionPickerHeight,
disableShowingAnimatedImages,
recentlyUsedEmojis, recentlyUsedEmojis,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
const pinned = computed(() => props.asReactionPicker ? pinnedReactions.value : []); // TODO: pinned const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1); const size = computed(() => emojiPickerScale.value);
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); const width = computed(() => emojiPickerWidth.value);
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); const height = computed(() => emojiPickerHeight.value);
const q = ref<string>(''); const q = ref<string>('');
const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]); const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
const customEmojiFolderRoot: CustomEmojiFolderTree = { value: "", category: "", children: [] }; const customEmojiFolderRoot: CustomEmojiFolderTree = { value: '', category: '', children: [] };
function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree { function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
const parts = input.split('/').map(p => p.trim()); const parts = input.split('/').map(p => p.trim());
@ -368,7 +367,7 @@ function chosen(emoji: any, ev?: MouseEvent) {
emit('chosen', key); emit('chosen', key);
// 使 // 使
if (!pinned.value.includes(key)) { if (!pinned.value?.includes(key)) {
let recents = defaultStore.state.recentlyUsedEmojis; let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== key); recents = recents.filter((emoji: any) => emoji !== key);
recents.unshift(key); recents.unshift(key);

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="modal" ref="modal"
v-slot="{ type, maxHeight }" v-slot="{ type, maxHeight }"
:zPriority="'middle'" :zPriority="'middle'"
:preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" :preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:transparentBg="true" :transparentBg="true"
:manualShowing="manualShowing" :manualShowing="manualShowing"
:src="src" :src="src"
@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_popup _shadow" class="_popup _shadow"
:class="{ [$style.drawer]: type === 'drawer' }" :class="{ [$style.drawer]: type === 'drawer' }"
:showPinned="showPinned" :showPinned="showPinned"
:pinnedEmojis="pinnedEmojis"
:asReactionPicker="asReactionPicker" :asReactionPicker="asReactionPicker"
:asDrawer="type === 'drawer'" :asDrawer="type === 'drawer'"
:max-height="maxHeight" :max-height="maxHeight"
@ -40,11 +41,13 @@ const props = withDefaults(defineProps<{
manualShowing?: boolean | null; manualShowing?: boolean | null;
src?: HTMLElement; src?: HTMLElement;
showPinned?: boolean; showPinned?: boolean;
pinnedEmojis?: string[],
asReactionPicker?: boolean; asReactionPicker?: boolean;
choseAndClose?: boolean; choseAndClose?: boolean;
}>(), { }>(), {
manualShowing: null, manualShowing: null,
showPinned: true, showPinned: true,
pinnedEmojis: undefined,
asReactionPicker: false, asReactionPicker: false,
choseAndClose: true, choseAndClose: true,
}); });

View file

@ -857,7 +857,7 @@ async function insertEmoji(ev: MouseEvent) {
}, },
() => { () => {
textAreaReadOnly.value = false; textAreaReadOnly.value = false;
focus(); nextTick(() => focus());
}, },
); );
} }

View file

@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="[$style.root, { [$style.rootFirst]: first }]"> <div :class="[$style.root, { [$style.rootFirst]: first }]">
<div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div> <div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
<div :class="[$style.description]"><slot name="description"></slot></div>
<div :class="$style.main"> <div :class="$style.main">
<slot></slot> <slot></slot>
</div> </div>
@ -31,7 +32,7 @@ defineProps<{
.label { .label {
font-weight: bold; font-weight: bold;
padding: 1.5em 0 0 0; padding: 1.5em 0 0 0;
margin: 0 0 16px 0; margin: 0 0 8px 0;
&:empty { &:empty {
display: none; display: none;
@ -45,4 +46,10 @@ defineProps<{
.main { .main {
margin: 1.5em 0 0 0; margin: 1.5em 0 0 0;
} }
.description {
font-size: 0.85em;
color: var(--fgTransparentWeak);
margin: 0 0 8px 0;
}
</style> </style>

View file

@ -0,0 +1,274 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojisForReaction"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseReaction">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="copyFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.copyFromPinnedEmojis }}</MkButton>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-pin"></i></template>
<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template>
<template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template>
<div class="_gaps">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="pinnedEmojis"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.emojisAdd" @click="chooseEmoji">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
<div class="_buttons">
<MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton inline danger @click="copyFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.copyFromPinnedEmojisForReaction }}</MkButton>
</div>
</div>
</MkFolder>
<FormSection>
<template #label>{{ i18n.ts.emojiPickerDisplay }}</template>
<div class="_gaps_m">
<MkRadios v-model="emojiPickerScale">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="emojiPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="emojiPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="emojiPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, Ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
import MkFolder from '@/components/MkFolder.vue';
const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(defaultStore.state.reactions));
const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmojis));
const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale'));
const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth'));
const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight'));
const emojiPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('emojiPickerUseDrawerForMobile'));
const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);
const setDefaultReaction = () => setDefault(pinnedEmojisForReaction);
const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev);
const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev);
const setDefaultEmoji = () => setDefault(pinnedEmojis);
function previewReaction(ev: MouseEvent) {
reactionPicker.show(getHTMLElement(ev));
}
function previewEmoji(ev: MouseEvent) {
emojiPicker.show(getHTMLElement(ev));
}
async function copyFromPinnedEmojis() {
const { canceled } = await os.confirm({
type: 'warning',
text: 'a',
});
if (canceled) {
return;
}
pinnedEmojisForReaction.value = [...pinnedEmojis.value];
}
async function copyFromPinnedEmojisForReaction() {
const { canceled } = await os.confirm({
type: 'warning',
text: 'a',
});
if (canceled) {
return;
}
pinnedEmojis.value = [...pinnedEmojisForReaction.value];
}
function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
itemsRef.value = itemsRef.value.filter(x => x !== reaction);
},
}], getHTMLElement(ev));
}
async function setDefault(itemsRef: Ref<string[]>) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
itemsRef.value = deepClone(defaultStore.def.reactions.default);
}
async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(it => {
const emoji = it as string;
if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji);
}
});
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
watch(pinnedEmojisForReaction, () => {
defaultStore.set('reactions', pinnedEmojisForReaction.value);
}, {
deep: true,
});
watch(pinnedEmojis, () => {
defaultStore.set('pinnedEmojis', pinnedEmojis.value);
}, {
deep: true,
});
definePageMetadata({
title: i18n.ts.emojiPicker,
icon: 'ti ti-mood-happy',
});
</script>
<style lang="scss" module>
.tab {
margin: calc(var(--margin) / 2) 0;
padding: calc(var(--margin) / 2) 0;
background: var(--bg);
}
.emojis {
padding: 12px;
font-size: 1.1em;
}
.emojisItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.emojisAdd {
display: inline-block;
padding: 8px;
}
.editorCaption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
}
</style>

View file

@ -74,9 +74,9 @@ const menuDef = computed(() => [{
active: currentPage.value?.route.name === 'privacy', active: currentPage.value?.route.name === 'privacy',
}, { }, {
icon: 'ti ti-mood-happy', icon: 'ti ti-mood-happy',
text: i18n.ts.reaction, text: i18n.ts.emojiPicker,
to: '/settings/reaction', to: '/settings/emoji-picker',
active: currentPage.value?.route.name === 'reaction', active: currentPage.value?.route.name === 'emojiPicker',
}, { }, {
icon: 'ti ti-cloud', icon: 'ti ti-cloud',
text: i18n.ts.drive, text: i18n.ts.drive,
@ -236,7 +236,7 @@ provideMetadataReceiver((info) => {
childInfo.value = null; childInfo.value = null;
} else { } else {
childInfo.value = info; childInfo.value = info;
INFO.value.needWideArea = info.value?.needWideArea ?? undefined; INFO.value.needWideArea = info.value.needWideArea ?? undefined;
} }
}); });

View file

@ -83,10 +83,10 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useReactionPickerForContextMenu', 'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline', 'showGapBetweenNotesInTimeline',
'instanceTicker', 'instanceTicker',
'reactionPickerSize', 'emojiPickerScale',
'reactionPickerWidth', 'emojiPickerWidth',
'reactionPickerHeight', 'emojiPickerHeight',
'reactionPickerUseDrawerForMobile', 'emojiPickerUseDrawerForMobile',
'defaultSideView', 'defaultSideView',
'menuDisplay', 'menuDisplay',
'reportError', 'reportError',

View file

@ -1,159 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<FromSlot>
<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
<div v-panel style="border-radius: 6px;">
<Sortable v-model="reactions" :class="$style.reactions" :itemKey="item => item" :animation="150" :delay="100" :delayOnTouchOnly="true">
<template #item="{element}">
<button class="_button" :class="$style.reactionsItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
</template>
</Sortable>
</div>
<template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
</FromSlot>
<MkRadios v-model="reactionPickerSize">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="reactionPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="reactionPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="reactionPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
<FormSection>
<div class="_buttons">
<MkButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, watch, ref, computed } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import FromSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
const reactions = ref(deepClone(defaultStore.state.reactions));
const reactionPickerSize = computed(defaultStore.makeGetterSetter('reactionPickerSize'));
const reactionPickerWidth = computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
const reactionPickerHeight = computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
const reactionPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
function save() {
defaultStore.set('reactions', reactions.value);
}
function remove(reaction, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
reactions.value = reactions.value.filter(x => x !== reaction);
},
}], ev.currentTarget ?? ev.target);
}
function preview(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
asReactionPicker: true,
src: ev.currentTarget ?? ev.target,
}, {}, 'closed');
}
async function setDefault() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
reactions.value = deepClone(defaultStore.def.reactions.default);
}
function chooseEmoji(ev: MouseEvent) {
os.pickEmoji(ev.currentTarget ?? ev.target, {
showPinned: false,
}).then(emoji => {
if (!reactions.value.includes(emoji)) {
reactions.value.push(emoji);
}
});
}
watch(reactions, () => {
save();
}, {
deep: true,
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata({
title: i18n.ts.reaction,
icon: 'ti ti-mood-happy',
action: {
icon: 'ti ti-eye',
handler: preview,
},
});
</script>
<style lang="scss" module>
.reactions {
padding: 12px;
font-size: 1.1em;
}
.reactionsItem {
display: inline-block;
padding: 8px;
cursor: move;
}
.reactionsAdd {
display: inline-block;
padding: 8px;
}
</style>

View file

@ -63,9 +63,9 @@ export const routes = [{
name: 'privacy', name: 'privacy',
component: page(() => import('./pages/settings/privacy.vue')), component: page(() => import('./pages/settings/privacy.vue')),
}, { }, {
path: '/reaction', path: '/emoji-picker',
name: 'reaction', name: 'emojiPicker',
component: page(() => import('./pages/settings/reaction.vue')), component: page(() => import('./pages/settings/emoji-picker.vue')),
}, { }, {
path: '/drive', path: '/drive',
name: 'drive', name: 'drive',

View file

@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { defineAsyncComponent, Ref, ref } from 'vue'; import { defineAsyncComponent, Ref, ref, computed, ComputedRef } from 'vue';
import { popup } from '@/os.js'; import { popup } from '@/os.js';
import { defaultStore } from '@/store.js';
/** /**
* *
@ -23,8 +24,10 @@ class EmojiPicker {
} }
public async init() { public async init() {
const emojisRef = defaultStore.reactiveState.pinnedEmojis;
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src, src: this.src,
pinnedEmojis: emojisRef,
asReactionPicker: false, asReactionPicker: false,
manualShowing: this.manualShowing, manualShowing: this.manualShowing,
choseAndClose: false, choseAndClose: false,
@ -44,8 +47,8 @@ class EmojiPicker {
public show( public show(
src: HTMLElement, src: HTMLElement,
onChosen: EmojiPicker['onChosen'], onChosen?: EmojiPicker['onChosen'],
onClosed: EmojiPicker['onClosed'], onClosed?: EmojiPicker['onClosed'],
) { ) {
this.src.value = src; this.src.value = src;
this.manualShowing.value = true; this.manualShowing.value = true;

View file

@ -5,6 +5,7 @@
import { defineAsyncComponent, Ref, ref } from 'vue'; import { defineAsyncComponent, Ref, ref } from 'vue';
import { popup } from '@/os.js'; import { popup } from '@/os.js';
import { defaultStore } from '@/store.js';
class ReactionPicker { class ReactionPicker {
private src: Ref<HTMLElement | null> = ref(null); private src: Ref<HTMLElement | null> = ref(null);
@ -17,25 +18,27 @@ class ReactionPicker {
} }
public async init() { public async init() {
const reactionsRef = defaultStore.reactiveState.reactions;
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src, src: this.src,
pinnedEmojis: reactionsRef,
asReactionPicker: true, asReactionPicker: true,
manualShowing: this.manualShowing, manualShowing: this.manualShowing,
}, { }, {
done: reaction => { done: reaction => {
this.onChosen!(reaction); if (this.onChosen) this.onChosen(reaction);
}, },
close: () => { close: () => {
this.manualShowing.value = false; this.manualShowing.value = false;
}, },
closed: () => { closed: () => {
this.src.value = null; this.src.value = null;
this.onClosed!(); if (this.onClosed) this.onClosed();
}, },
}); });
} }
public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) { public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
this.src.value = src; this.src.value = src;
this.manualShowing.value = true; this.manualShowing.value = true;
this.onChosen = onChosen; this.onChosen = onChosen;

View file

@ -119,6 +119,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
}, },
pinnedEmojis: {
where: 'account',
default: [],
},
reactionAcceptance: { reactionAcceptance: {
where: 'account', where: 'account',
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
@ -271,19 +275,19 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: 'remote' as 'none' | 'remote' | 'always', default: 'remote' as 'none' | 'remote' | 'always',
}, },
reactionPickerSize: { emojiPickerScale: {
where: 'device', where: 'device',
default: 1, default: 1,
}, },
reactionPickerWidth: { emojiPickerWidth: {
where: 'device', where: 'device',
default: 1, default: 1,
}, },
reactionPickerHeight: { emojiPickerHeight: {
where: 'device', where: 'device',
default: 2, default: 2,
}, },
reactionPickerUseDrawerForMobile: { emojiPickerUseDrawerForMobile: {
where: 'device', where: 'device',
default: true, default: true,
}, },