merge upstream for 2024.2.1
This commit is contained in:
parent
eab7d5bd27
commit
af548d05ca
137 changed files with 4524 additions and 2933 deletions
|
@ -401,7 +401,8 @@ function toStories(component: string): Promise<string> {
|
|||
// glob('src/{components,pages,ui,widgets}/**/*.vue')
|
||||
(async () => {
|
||||
const globs = await Promise.all([
|
||||
glob('src/components/global/*.vue'),
|
||||
glob('src/components/global/Mk*.vue'),
|
||||
glob('src/components/global/RouterView.vue'),
|
||||
glob('src/components/Mk{A,B}*.vue'),
|
||||
glob('src/components/MkDigitalClock.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
|
|
|
@ -28,19 +28,19 @@
|
|||
"@syuilo/aiscript": "0.17.0",
|
||||
"@phosphor-icons/web": "^2.0.3",
|
||||
"@twemoji/parser": "15.0.0",
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"@vue/compiler-sfc": "3.4.15",
|
||||
"@vitejs/plugin-vue": "5.0.4",
|
||||
"@vue/compiler-sfc": "3.4.21",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2",
|
||||
"astring": "1.8.6",
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.6.1",
|
||||
"chart.js": "4.4.1",
|
||||
"canvas-confetti": "1.9.2",
|
||||
"chart.js": "4.4.2",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "10.6.1",
|
||||
"chromatic": "11.0.0",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "2.30.0",
|
||||
|
@ -58,79 +58,79 @@
|
|||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.3",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.9.6",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sass": "1.70.0",
|
||||
"shiki": "1.0.0-beta.3",
|
||||
"rollup": "4.12.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"sass": "1.71.1",
|
||||
"shiki": "1.1.7",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.160.1",
|
||||
"three": "0.162.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.3.3",
|
||||
"uuid": "9.0.1",
|
||||
"v-code-diff": "1.7.2",
|
||||
"vite": "5.1.0",
|
||||
"vue": "3.4.15",
|
||||
"v-code-diff": "1.9.0",
|
||||
"vite": "5.1.4",
|
||||
"vue": "3.4.21",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@misskey-dev/summaly": "5.0.3",
|
||||
"@storybook/addon-actions": "8.0.0-beta.2",
|
||||
"@storybook/addon-essentials": "8.0.0-beta.2",
|
||||
"@storybook/addon-interactions": "8.0.0-beta.2",
|
||||
"@storybook/addon-links": "8.0.0-beta.2",
|
||||
"@storybook/addon-mdx-gfm": "8.0.0-beta.2",
|
||||
"@storybook/addon-storysource": "8.0.0-beta.2",
|
||||
"@storybook/blocks": "8.0.0-beta.2",
|
||||
"@storybook/components": "8.0.0-beta.2",
|
||||
"@storybook/core-events": "8.0.0-beta.2",
|
||||
"@storybook/manager-api": "8.0.0-beta.2",
|
||||
"@storybook/preview-api": "8.0.0-beta.2",
|
||||
"@storybook/react": "8.0.0-beta.2",
|
||||
"@storybook/react-vite": "8.0.0-beta.2",
|
||||
"@storybook/test": "8.0.0-beta.2",
|
||||
"@storybook/theming": "8.0.0-beta.2",
|
||||
"@storybook/types": "8.0.0-beta.2",
|
||||
"@storybook/vue3": "8.0.0-beta.2",
|
||||
"@storybook/vue3-vite": "8.0.0-beta.2",
|
||||
"@storybook/addon-actions": "8.0.0-beta.6",
|
||||
"@storybook/addon-essentials": "8.0.0-beta.6",
|
||||
"@storybook/addon-interactions": "8.0.0-beta.6",
|
||||
"@storybook/addon-links": "8.0.0-beta.6",
|
||||
"@storybook/addon-mdx-gfm": "8.0.0-beta.6",
|
||||
"@storybook/addon-storysource": "8.0.0-beta.6",
|
||||
"@storybook/blocks": "8.0.0-beta.6",
|
||||
"@storybook/components": "8.0.0-beta.6",
|
||||
"@storybook/core-events": "8.0.0-beta.6",
|
||||
"@storybook/manager-api": "8.0.0-beta.6",
|
||||
"@storybook/preview-api": "8.0.0-beta.6",
|
||||
"@storybook/react": "8.0.0-beta.6",
|
||||
"@storybook/react-vite": "8.0.0-beta.6",
|
||||
"@storybook/test": "8.0.0-beta.6",
|
||||
"@storybook/theming": "8.0.0-beta.6",
|
||||
"@storybook/types": "8.0.0-beta.6",
|
||||
"@storybook/vue3": "8.0.0-beta.6",
|
||||
"@storybook/vue3-vite": "8.0.0-beta.6",
|
||||
"@testing-library/vue": "8.0.2",
|
||||
"@types/escape-regexp": "0.0.3",
|
||||
"@types/estree": "1.0.5",
|
||||
"@types/matter-js": "0.19.6",
|
||||
"@types/micromatch": "4.0.6",
|
||||
"@types/node": "20.11.17",
|
||||
"@types/punycode": "2.1.3",
|
||||
"@types/sanitize-html": "2.9.5",
|
||||
"@types/node": "20.11.22",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "6.18.1",
|
||||
"@typescript-eslint/parser": "6.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"@vitest/coverage-v8": "0.34.6",
|
||||
"@vue/runtime-core": "3.4.15",
|
||||
"@vue/runtime-core": "3.4.21",
|
||||
"acorn": "8.11.3",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.6.4",
|
||||
"eslint": "8.56.0",
|
||||
"cypress": "13.6.6",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-vue": "9.20.1",
|
||||
"eslint-plugin-vue": "9.22.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
"happy-dom": "13.6.2",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.5",
|
||||
"msw": "2.1.7",
|
||||
"msw-storybook-addon": "2.0.0-beta.1",
|
||||
"nodemon": "3.0.3",
|
||||
"nodemon": "3.1.0",
|
||||
"prettier": "3.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.3",
|
||||
"storybook": "8.0.0-beta.2",
|
||||
"storybook": "8.0.0-beta.6",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "0.34.6",
|
||||
|
|
|
@ -290,7 +290,7 @@ export async function openAccountMenu(opts: {
|
|||
text: i18n.ts.profile,
|
||||
to: `/@${ $i.username }`,
|
||||
avatar: $i,
|
||||
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
type: 'parent' as const,
|
||||
icon: 'ph-plus ph-bold ph-lg',
|
||||
text: i18n.ts.addAccount,
|
||||
|
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</ol>
|
||||
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
|
||||
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
|
||||
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/>
|
||||
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
|
||||
|
@ -77,7 +77,7 @@ const emojiDb = computed(() => {
|
|||
unicodeEmojiDB.push({
|
||||
emoji: emoji,
|
||||
name: k,
|
||||
aliasOf: getEmojiName(emoji)!,
|
||||
aliasOf: getEmojiName(emoji),
|
||||
url: char2path(emoji),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
|
||||
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
|
@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
|
||||
type Input = {
|
||||
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
|
@ -74,22 +69,17 @@ type Input = {
|
|||
|
||||
type Select = {
|
||||
items: {
|
||||
value: string;
|
||||
value: any;
|
||||
text: string;
|
||||
}[];
|
||||
groupedItems: {
|
||||
label: string;
|
||||
items: {
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
}[];
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
type Result = string | number | true | null;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
|
||||
title: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
input?: Input;
|
||||
select?: Select;
|
||||
|
@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { canceled: boolean; result: any }): void;
|
||||
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
|
@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
|
|||
return null;
|
||||
});
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
emit('done', { canceled, result });
|
||||
// overload function を使いたいので lint エラーを無視する
|
||||
function done(canceled: true): void;
|
||||
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
|
||||
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
|
||||
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
|
|
|
@ -39,13 +39,13 @@ withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFile[]>([]);
|
||||
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
|
@ -57,7 +57,7 @@ function cancel() {
|
|||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
|
||||
selected.value = files;
|
||||
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
|
||||
selected.value = v;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -16,10 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:key="emoji"
|
||||
:data-emoji="emoji"
|
||||
class="_button item"
|
||||
:disabled="disabledEmojis?.value.includes(emoji)"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="emit('chosen', emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:key="emoji"
|
||||
:data-emoji="emoji"
|
||||
class="_button item"
|
||||
:disabled="disabledEmojis?.value.includes(emoji)"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="emit('chosen', emoji, $event)"
|
||||
>
|
||||
|
@ -67,6 +69,7 @@ import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
emojis: string[] | Ref<string[]>;
|
||||
disabledEmojis?: Ref<string[]>;
|
||||
initialShown?: boolean;
|
||||
hasChildSection?: boolean;
|
||||
customEmojiTree?: CustomEmojiFolderTree[];
|
||||
|
@ -84,7 +87,7 @@ const shown = ref(!!props.initialShown);
|
|||
function computeButtonTitle(ev: MouseEvent): void {
|
||||
const elm = ev.target as HTMLElement;
|
||||
const emoji = elm.dataset.emoji as string;
|
||||
elm.title = getEmojiName(emoji) ?? emoji;
|
||||
elm.title = getEmojiName(emoji);
|
||||
}
|
||||
|
||||
function nestedChosen(emoji: any, ev: MouseEvent) {
|
||||
|
|
|
@ -14,11 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-for="emoji in searchResultCustom"
|
||||
:key="emoji.name"
|
||||
class="_button item"
|
||||
:disabled="!canReact(emoji)"
|
||||
:title="emoji.name"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji class="emoji" :name="emoji.name"/>
|
||||
<MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchResultUnicode.length > 0" class="body">
|
||||
|
@ -39,16 +40,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<section v-if="showPinned && (pinned && pinned.length > 0)">
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="emoji in pinned"
|
||||
:key="emoji"
|
||||
:data-emoji="emoji"
|
||||
v-for="emoji in pinnedEmojisDef"
|
||||
:key="getKey(emoji)"
|
||||
:data-emoji="getKey(emoji)"
|
||||
class="_button item"
|
||||
:disabled="!canReact(emoji)"
|
||||
tabindex="0"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
|
||||
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -57,15 +59,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<header class="_acrylic"><i class="ph-clock ph-bold ph-lg ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="emoji in recentlyUsedEmojis"
|
||||
:key="emoji"
|
||||
v-for="emoji in recentlyUsedEmojisDef"
|
||||
:key="getKey(emoji)"
|
||||
class="_button item"
|
||||
:data-emoji="emoji"
|
||||
:disabled="!canReact(emoji)"
|
||||
:data-emoji="getKey(emoji)"
|
||||
@pointerenter="computeButtonTitle"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
|
||||
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-for="child in customEmojiFolderRoot.children"
|
||||
:key="`custom:${child.value}`"
|
||||
:initialShown="false"
|
||||
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))"
|
||||
:emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))"
|
||||
:disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))"
|
||||
:hasChildSection="child.children.length !== 0"
|
||||
:customEmojiTree="child.children"
|
||||
@chosen="chosen"
|
||||
|
@ -109,6 +113,7 @@ import {
|
|||
unicodeEmojiCategories as categories,
|
||||
getEmojiName,
|
||||
CustomEmojiFolderTree,
|
||||
getUnicodeEmoji,
|
||||
} from '@/scripts/emojilist.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import * as os from '@/os.js';
|
||||
|
@ -146,6 +151,13 @@ const {
|
|||
recentlyUsedEmojis,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const recentlyUsedEmojisDef = computed(() => {
|
||||
return recentlyUsedEmojis.value.map(getDef).filter(x => x != null);
|
||||
});
|
||||
const pinnedEmojisDef = computed(() => {
|
||||
return pinned.value?.map(getDef).filter(x => x != null);
|
||||
});
|
||||
|
||||
const pinned = computed(() => props.pinnedEmojis);
|
||||
const size = computed(() => emojiPickerScale.value);
|
||||
const width = computed(() => emojiPickerWidth.value);
|
||||
|
@ -337,14 +349,18 @@ watch(q, () => {
|
|||
return matches;
|
||||
};
|
||||
|
||||
searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable);
|
||||
searchResultCustom.value = Array.from(searchCustom());
|
||||
searchResultUnicode.value = Array.from(searchUnicode());
|
||||
});
|
||||
|
||||
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean {
|
||||
function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
|
||||
return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
|
||||
}
|
||||
|
||||
function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean {
|
||||
return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
|
||||
searchEl.value?.focus({
|
||||
|
@ -362,11 +378,22 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef):
|
|||
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
|
||||
}
|
||||
|
||||
function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef {
|
||||
if (emoji.includes(':')) {
|
||||
// カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、
|
||||
// サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておく(undefinedを返すとエラーになるため)
|
||||
const name = emoji.replaceAll(':', '');
|
||||
return customEmojisMap.get(name) ?? emoji;
|
||||
} else {
|
||||
return getUnicodeEmoji(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
/** @see MkEmojiPicker.section.vue */
|
||||
function computeButtonTitle(ev: MouseEvent): void {
|
||||
const elm = ev.target as HTMLElement;
|
||||
const emoji = elm.dataset.emoji as string;
|
||||
elm.title = getEmojiName(emoji) ?? emoji;
|
||||
elm.title = getEmojiName(emoji);
|
||||
}
|
||||
|
||||
function chosen(emoji: any, ev?: MouseEvent) {
|
||||
|
@ -526,6 +553,18 @@ defineExpose({
|
|||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
|
||||
opacity: 1;
|
||||
|
||||
> .emoji {
|
||||
filter: grayscale(1);
|
||||
mix-blend-mode: exclusion;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -548,6 +587,18 @@ defineExpose({
|
|||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
|
||||
opacity: 1;
|
||||
|
||||
> .emoji {
|
||||
filter: grayscale(1);
|
||||
mix-blend-mode: exclusion;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -663,6 +714,18 @@ defineExpose({
|
|||
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
|
||||
opacity: 1;
|
||||
|
||||
> .emoji {
|
||||
filter: grayscale(1);
|
||||
mix-blend-mode: exclusion;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
height: 1.25em;
|
||||
vertical-align: -.25em;
|
||||
|
|
|
@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: any): void;
|
||||
(ev: 'done', v: string): void;
|
||||
(ev: 'close'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
@ -64,7 +64,7 @@ const emit = defineEmits<{
|
|||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
|
||||
|
||||
function chosen(emoji: any) {
|
||||
function chosen(emoji: string) {
|
||||
emit('done', emoji);
|
||||
if (props.choseAndClose) {
|
||||
modal.value?.close();
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkWindow
|
||||
ref="window"
|
||||
:initialWidth="300"
|
||||
:initialHeight="290"
|
||||
:canResize="true"
|
||||
:mini="true"
|
||||
:front="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
src?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
targetNote?: Misskey.entities.Note
|
||||
}>(), {
|
||||
showPinned: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: any): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
function chosen(emoji: any) {
|
||||
emit('chosen', emoji);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.picker {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
|
@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<MkSpacer :marginMin="20" :marginMax="32">
|
||||
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
|
||||
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
|
||||
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))">
|
||||
<MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
|
||||
<span v-text="form[item].label || item"></span>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
|
||||
<span v-text="v.label || k"></span>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkSwitch>
|
||||
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</MkSelect>
|
||||
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</MkRadios>
|
||||
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkRange>
|
||||
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
|
||||
<span v-text="form[item].content || item"></span>
|
||||
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
|
||||
<span v-text="v.content || k"></span>
|
||||
</MkButton>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -72,19 +72,21 @@ import MkSelect from './MkSelect.vue';
|
|||
import MkRange from './MkRange.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkRadios from './MkRadios.vue';
|
||||
import type { Form } from '@/scripts/form.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
form: any;
|
||||
form: Form;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: {
|
||||
canceled?: boolean;
|
||||
result?: any;
|
||||
canceled: true;
|
||||
} | {
|
||||
result: Record<string, any>;
|
||||
}): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
|
|
@ -166,7 +166,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="tab === 'replies'" :class="$style.tab_replies">
|
||||
<div v-if="tab === 'replies'">
|
||||
<div v-if="!repliesLoaded" style="padding: 16px">
|
||||
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
|
||||
</div>
|
||||
|
@ -183,7 +183,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-if="tab === 'quotes'" :class="$style.tab_replies">
|
||||
<div v-if="tab === 'quotes'">
|
||||
<div v-if="!quotesLoaded" style="padding: 16px">
|
||||
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
|
||||
</div>
|
||||
|
|
|
@ -40,6 +40,7 @@ import { notificationTypes } from '@/const.js';
|
|||
import { infoImageUrl } from '@/instance.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
const props = defineProps<{
|
||||
excludeTypes?: typeof notificationTypes[number][];
|
||||
|
@ -80,17 +81,19 @@ function reload() {
|
|||
});
|
||||
}
|
||||
|
||||
let connection;
|
||||
let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
|
||||
|
||||
onMounted(() => {
|
||||
connection = useStream().useChannel('main');
|
||||
connection.on('notification', onNotification);
|
||||
connection.on('notificationFlushed', reload);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
pagingComponent.value?.reload();
|
||||
connection = useStream().useChannel('main');
|
||||
connection.on('notification', onNotification);
|
||||
connection.on('notificationFlushed', reload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
|
@ -174,7 +174,7 @@ const emit = defineEmits<{
|
|||
const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
|
||||
const cwInputEl = shallowRef<HTMLInputElement | null>(null);
|
||||
const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
|
||||
const visibilityButton = shallowRef<HTMLElement | null>(null);
|
||||
const visibilityButton = shallowRef<HTMLElement>();
|
||||
|
||||
const posting = ref(false);
|
||||
const posted = ref(false);
|
||||
|
@ -467,6 +467,7 @@ function setVisibility() {
|
|||
isSilenced: $i.isSilenced,
|
||||
localOnly: localOnly.value,
|
||||
src: visibilityButton.value,
|
||||
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
|
||||
}, {
|
||||
changeVisibility: v => {
|
||||
visibility.value = v;
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/>
|
||||
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ function getReactionName(reaction: string): string {
|
|||
if (trimLocal.startsWith(':')) {
|
||||
return trimLocal;
|
||||
}
|
||||
return getEmojiName(reaction) ?? reaction;
|
||||
return getEmojiName(reaction);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -33,7 +33,8 @@ import { defaultStore } from '@/store.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import { customEmojisMap } from '@/custom-emojis.js';
|
||||
import { getUnicodeEmoji } from '@/scripts/emojilist.js';
|
||||
|
||||
const props = defineProps<{
|
||||
reaction: string;
|
||||
|
@ -50,13 +51,11 @@ const emit = defineEmits<{
|
|||
|
||||
const buttonEl = shallowRef<HTMLElement>();
|
||||
|
||||
const isCustomEmoji = computed(() => props.reaction.includes(':'));
|
||||
const emoji = computed(() => isCustomEmoji.value ? customEmojis.value.find(emoji => emoji.name === props.reaction.replace(/:/g, '').replace(/@\./, '')) : null);
|
||||
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
|
||||
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
|
||||
|
||||
const canToggle = computed(() => {
|
||||
return !props.reaction.match(/@\w/) && $i
|
||||
&& (emoji.value && checkReactionPermissions($i, props.note, emoji.value))
|
||||
|| !isCustomEmoji.value;
|
||||
return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
|
||||
});
|
||||
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</dt>
|
||||
<dd :class="$style.fieldvalue">
|
||||
<Mfm :text="field.value" :nyaize="false" :author="user" :colored="false"/>
|
||||
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg" :class="$style.verifiedLink"></i>
|
||||
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg"></i>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
|
@ -9,21 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="[$style.label, $style.item]">
|
||||
{{ i18n.ts.visibility }}
|
||||
</div>
|
||||
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<div :class="$style.icon"><i class="ph-globe-hemisphere-west ph-bold ph-lg"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
|
||||
<button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
|
||||
<div :class="$style.icon"><i class="ph-house ph-bold ph-lg"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
|
||||
<button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
|
||||
<div :class="$style.icon"><i class="ph-lock ph-bold ph-lg"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span>
|
||||
|
@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{
|
|||
isSilenced: boolean;
|
||||
localOnly: boolean;
|
||||
src?: HTMLElement;
|
||||
isReplyVisibilitySpecified?: boolean;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<template v-if="appearNote.reply && appearNote.reply.replyId">
|
||||
<SkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws" detailed/>
|
||||
<SkNoteSub v-for="note in conversation" :key="note.id" :note="note" :expandAllCws="props.expandAllCws" detailed/>
|
||||
</template>
|
||||
<SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed/>
|
||||
<article :id="appearNote.id" ref="noteEl" :class="$style.note" tabindex="-1" @contextmenu.stop="onContextmenu">
|
||||
|
@ -174,7 +174,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="tab === 'replies'" :class="$style.tab_replies">
|
||||
<div v-if="tab === 'replies'">
|
||||
<div v-if="!repliesLoaded" style="padding: 16px">
|
||||
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
|
||||
</div>
|
||||
|
@ -191,7 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-if="tab === 'quotes'" :class="$style.tab_replies">
|
||||
<div v-if="tab === 'quotes'">
|
||||
<div v-if="!quotesLoaded" style="padding: 16px">
|
||||
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
|
||||
</div>
|
||||
|
@ -798,10 +798,6 @@ onUnmounted(() => {
|
|||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.replyToMore {
|
||||
|
||||
}
|
||||
|
||||
.renote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -32,7 +32,8 @@ export const Default = {
|
|||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
// FIXME: 通るけどその後落ちるのでコメントアウト
|
||||
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
await userEvent.pointer({ keys: '[MouseRight]', target: a });
|
||||
await tick();
|
||||
const menu = canvas.getByRole('menu');
|
||||
|
@ -44,6 +45,7 @@ export const Default = {
|
|||
},
|
||||
args: {
|
||||
to: '#test',
|
||||
behavior: 'browser',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
|
|
@ -48,3 +48,18 @@ export const Missing = {
|
|||
name: Default.args.name,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
export const ErrorToText = {
|
||||
...Default,
|
||||
args: {
|
||||
url: 'https://example.com/404',
|
||||
name: Default.args.name,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
export const ErrorToImage = {
|
||||
...Default,
|
||||
args: {
|
||||
url: 'https://example.com/404',
|
||||
name: Default.args.name,
|
||||
fallbackToImage: true,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||
|
|
|
@ -4,7 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<span v-if="errored">:{{ customEmojiName }}:</span>
|
||||
<img
|
||||
v-if="errored && fallbackToImage"
|
||||
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
|
||||
src="/client-assets/dummy.png"
|
||||
:title="alt"
|
||||
/>
|
||||
<span v-else-if="errored">:{{ customEmojiName }}:</span>
|
||||
<img
|
||||
v-else
|
||||
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
|
||||
|
@ -39,6 +45,7 @@ const props = defineProps<{
|
|||
useOriginalSize?: boolean;
|
||||
menu?: boolean;
|
||||
menuReaction?: boolean;
|
||||
fallbackToImage?: boolean;
|
||||
}>();
|
||||
|
||||
const react = inject<((name: string) => void) | null>('react', null);
|
||||
|
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js';
|
||||
import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
|
||||
import * as os from '@/os.js';
|
||||
|
@ -26,7 +26,7 @@ const props = defineProps<{
|
|||
|
||||
const react = inject<((name: string) => void) | null>('react', null);
|
||||
|
||||
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
|
||||
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.state.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
|
||||
|
||||
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
|
||||
const url = computed(() => char2path(props.emoji));
|
||||
|
@ -34,8 +34,7 @@ const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
|
|||
|
||||
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
|
||||
function computeTitle(event: PointerEvent): void {
|
||||
const title = getEmojiName(props.emoji as string) ?? props.emoji as string;
|
||||
(event.target as HTMLElement).title = title;
|
||||
(event.target as HTMLElement).title = getEmojiName(props.emoji);
|
||||
}
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
|
|
|
@ -410,6 +410,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
useOriginalSize: scale >= 2.5,
|
||||
menu: props.enableEmojiMenu,
|
||||
menuReaction: props.enableEmojiMenuReaction,
|
||||
fallbackToImage: false,
|
||||
})];
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
|
|
|
@ -10,7 +10,7 @@ import MkTime from './MkTime.vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { dateTimeFormat } from '@/scripts/intl-const.js';
|
||||
const now = new Date('2023-04-01T00:00:00.000Z');
|
||||
const future = new Date('3000-04-01T00:00:00.000Z');
|
||||
const future = new Date('2024-04-01T00:00:00.000Z');
|
||||
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||
const oneDayAgo = new Date(now.getTime() - 86400000);
|
||||
const oneWeekAgo = new Date(now.getTime() - 604800000);
|
||||
|
@ -49,7 +49,7 @@ export const Empty = {
|
|||
export const RelativeFuture = {
|
||||
...Empty,
|
||||
async play({ canvasElement }) {
|
||||
await expect(canvasElement).toHaveTextContent(i18n.tsx._timeIn.years({ n: 977 }));
|
||||
await expect(canvasElement).toHaveTextContent(i18n.tsx._timeIn.years({ n: 1 })); // n (1) = future (2024) - now (2023)
|
||||
},
|
||||
args: {
|
||||
...Empty.args,
|
||||
|
|
|
@ -128,6 +128,7 @@ export const ROLE_POLICIES = [
|
|||
'btlAvailable',
|
||||
'canPublicNote',
|
||||
'canImportNotes',
|
||||
'mentionLimit',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
'inviteLimitCycle',
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { ComponentProps } from 'vue-component-type-helpers';
|
||||
import type { ComponentProps as CP } from 'vue-component-type-helpers';
|
||||
import type { Form, GetFormResultType } from '@/scripts/form.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
|
||||
|
@ -19,7 +19,6 @@ import MkToast from '@/components/MkToast.vue';
|
|||
import MkDialog from '@/components/MkDialog.vue';
|
||||
import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
|
||||
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
|
||||
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
|
||||
import MkPopupMenu from '@/components/MkPopupMenu.vue';
|
||||
import MkContextMenu from '@/components/MkContextMenu.vue';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
|
@ -28,15 +27,15 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
|||
|
||||
export const openingWindowsCount = ref(0);
|
||||
|
||||
export const apiWithDialog = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
data: P = {} as any,
|
||||
token?: string | null | undefined,
|
||||
) => {
|
||||
const promise = misskeyApi(endpoint, data, token);
|
||||
promiseDialog(promise, null, async (err) => {
|
||||
let title = null;
|
||||
let text = err.message + '\n' + (err as any).id;
|
||||
let title: string | undefined;
|
||||
let text = err.message + '\n' + err.id;
|
||||
if (err.code === 'INTERNAL_ERROR') {
|
||||
title = i18n.ts.internalServerError;
|
||||
text = i18n.ts.internalServerErrorDescription;
|
||||
|
@ -88,7 +87,7 @@ export const apiWithDialog = ((
|
|||
export function promiseDialog<T extends Promise<any>>(
|
||||
promise: T,
|
||||
onSuccess?: ((res: any) => void) | null,
|
||||
onFailure?: ((err: Error) => void) | null,
|
||||
onFailure?: ((err: Misskey.api.APIError) => void) | null,
|
||||
text?: string,
|
||||
): T {
|
||||
const showing = ref(true);
|
||||
|
@ -156,14 +155,30 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number {
|
|||
// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する
|
||||
// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい
|
||||
type ComponentEmit<T> = T extends new () => { $props: infer Props }
|
||||
? EmitsExtractor<Props>
|
||||
: never;
|
||||
? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never]
|
||||
? Record<string, unknown> // *.ts ファイルから型がうまく取れないとき用(これがないと {} になって型エラーがうるさい)
|
||||
: EmitsExtractor<Props>
|
||||
: T extends (...args: any) => any
|
||||
? ReturnType<T> extends { [x: string]: any; __ctx?: { [x: string]: any; props: infer Props } }
|
||||
? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never]
|
||||
? Record<string, unknown>
|
||||
: EmitsExtractor<Props>
|
||||
: never
|
||||
: never;
|
||||
|
||||
// props に ref を許可するようにする
|
||||
type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> };
|
||||
|
||||
type EmitsExtractor<T> = {
|
||||
[K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events: ComponentEmit<T> = {} as ComponentEmit<T>, disposeEvent?: keyof ComponentEmit<T>) {
|
||||
export async function popup<T extends Component>(
|
||||
component: T,
|
||||
props: ComponentProps<T>,
|
||||
events: ComponentEmit<T> = {} as ComponentEmit<T>,
|
||||
disposeEvent?: keyof ComponentEmit<T>,
|
||||
): Promise<{ dispose: () => void }> {
|
||||
markRaw(component);
|
||||
|
||||
const id = ++popupIdCount;
|
||||
|
@ -204,12 +219,12 @@ export function toast(message: string) {
|
|||
|
||||
export function alert(props: {
|
||||
type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, props, {
|
||||
done: result => {
|
||||
done: () => {
|
||||
resolve();
|
||||
},
|
||||
}, 'closed');
|
||||
|
@ -218,12 +233,12 @@ export function alert(props: {
|
|||
|
||||
export function confirm(props: {
|
||||
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
}): Promise<{ canceled: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
...props,
|
||||
showCancelButton: true,
|
||||
|
@ -244,13 +259,15 @@ export function actions<T extends {
|
|||
danger?: boolean,
|
||||
}[]>(props: {
|
||||
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
actions: T;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: T[number]['value'];
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
...props,
|
||||
actions: props.actions.map(a => ({
|
||||
|
@ -269,19 +286,50 @@ export function actions<T extends {
|
|||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: string;
|
||||
}>;
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url';
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: string;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: string | null;
|
||||
}>;
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url';
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: string | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
|
@ -289,7 +337,7 @@ export function inputText(props: {
|
|||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
|
@ -301,16 +349,41 @@ export function inputText(props: {
|
|||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number;
|
||||
}>;
|
||||
export function inputNumber(props: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: number | null;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number | null;
|
||||
}>;
|
||||
export function inputNumber(props: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: number | null;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
|
@ -318,7 +391,7 @@ export function inputNumber(props: {
|
|||
type: 'number',
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
|
@ -329,34 +402,38 @@ export function inputNumber(props: {
|
|||
}
|
||||
|
||||
export function inputDate(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
default?: Date | null;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
default?: string | null;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: Date;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
input: {
|
||||
type: 'date',
|
||||
placeholder: props.placeholder,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
|
||||
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
|
||||
},
|
||||
}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | {
|
||||
export function authenticateDialog(): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: { password: string; token: string | null; };
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkPasswordDialog, {}, {
|
||||
done: result => {
|
||||
resolve(result ? { canceled: false, result } : { canceled: true, result: undefined });
|
||||
|
@ -365,34 +442,53 @@ export function authenticateDialog(): Promise<{ canceled: true; result: undefine
|
|||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function select<C = any>(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
default?: string | null;
|
||||
} & ({
|
||||
title?: string;
|
||||
text?: string;
|
||||
default: string;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
groupedItems: {
|
||||
label: string;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}[];
|
||||
})): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: C;
|
||||
}>;
|
||||
export function select<C = any>(props: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
default?: string | null;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: C | null;
|
||||
}>;
|
||||
export function select<C = any>(props: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
default?: string | null;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: C | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
select: {
|
||||
items: props.items,
|
||||
groupedItems: props.groupedItems,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
|
@ -403,7 +499,7 @@ export function select<C = any>(props: {
|
|||
}
|
||||
|
||||
export function success(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
const showing = ref(true);
|
||||
window.setTimeout(() => {
|
||||
showing.value = false;
|
||||
|
@ -418,7 +514,7 @@ export function success(): Promise<void> {
|
|||
}
|
||||
|
||||
export function waiting(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
const showing = ref(true);
|
||||
popup(MkWaitingDialog, {
|
||||
success: false,
|
||||
|
@ -429,9 +525,9 @@ export function waiting(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
export function form(title, form) {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, {
|
||||
export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
|
||||
done: result => {
|
||||
resolve(result);
|
||||
},
|
||||
|
@ -440,7 +536,7 @@ export function form(title, form) {
|
|||
}
|
||||
|
||||
export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
|
||||
includeSelf: opts.includeSelf,
|
||||
localOnly: opts.localOnly,
|
||||
|
@ -453,7 +549,7 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool
|
|||
}
|
||||
|
||||
export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
|
||||
type: 'file',
|
||||
multiple,
|
||||
|
@ -467,23 +563,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti
|
|||
});
|
||||
}
|
||||
|
||||
export async function selectDriveFolder(multiple: boolean) {
|
||||
return new Promise((resolve, reject) => {
|
||||
export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
|
||||
type: 'folder',
|
||||
multiple,
|
||||
}, {
|
||||
done: folders => {
|
||||
if (folders) {
|
||||
resolve(multiple ? folders : folders[0]);
|
||||
resolve(folders);
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickEmoji(src: HTMLElement | null, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
popup(MkEmojiPickerDialog, {
|
||||
src,
|
||||
...opts,
|
||||
|
@ -499,7 +595,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
|
|||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}): Promise<Misskey.entities.DriveFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
|
||||
file: image,
|
||||
aspectRatio: options.aspectRatio,
|
||||
|
@ -512,67 +608,13 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
|
|||
});
|
||||
}
|
||||
|
||||
type AwaitType<T> =
|
||||
T extends Promise<infer U> ? U :
|
||||
T extends (...args: any[]) => Promise<infer V> ? V :
|
||||
T;
|
||||
let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null;
|
||||
let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
|
||||
export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) {
|
||||
if (openingEmojiPicker) return;
|
||||
|
||||
activeTextarea = initialTextarea;
|
||||
|
||||
const textareas = document.querySelectorAll('textarea, input');
|
||||
for (const textarea of Array.from(textareas)) {
|
||||
textarea.addEventListener('focus', () => {
|
||||
activeTextarea = textarea;
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) {
|
||||
const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>;
|
||||
for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) {
|
||||
if (document.activeElement === textarea) activeTextarea = textarea;
|
||||
textarea.addEventListener('focus', () => {
|
||||
activeTextarea = textarea;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false,
|
||||
});
|
||||
|
||||
openingEmojiPicker = await popup(MkEmojiPickerWindow, {
|
||||
src,
|
||||
...opts,
|
||||
}, {
|
||||
chosen: emoji => {
|
||||
insertTextAtCursor(activeTextarea, emoji);
|
||||
},
|
||||
closed: () => {
|
||||
openingEmojiPicker!.dispose();
|
||||
openingEmojiPicker = null;
|
||||
observer.disconnect();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: {
|
||||
export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
|
||||
align?: string;
|
||||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
onClosing?: () => void;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
let dispose;
|
||||
popup(MkPopupMenu, {
|
||||
items,
|
||||
|
@ -594,9 +636,9 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
|
|||
});
|
||||
}
|
||||
|
||||
export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> {
|
||||
export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
|
||||
ev.preventDefault();
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
let dispose;
|
||||
popup(MkContextMenu, {
|
||||
items,
|
||||
|
@ -615,7 +657,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
|
|||
export function post(props: Record<string, any> = {}): Promise<void> {
|
||||
showMovedDialog();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
|
||||
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
|
||||
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
|
||||
|
|
|
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="misskey">Sharkey</div>
|
||||
<div class="version">v{{ version }}</div>
|
||||
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }">
|
||||
<MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true"/>
|
||||
<MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :noStyle="true"/>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -124,7 +124,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-for="role in info.roles" :key="role.id">
|
||||
<div :class="$style.roleItemMain">
|
||||
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
|
||||
<button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ph-caret-down ph-bold ph-lg"></i></button>
|
||||
<button class="_button" @click="toggleRoleItem(role)"><i class="ph-caret-down ph-bold ph-lg"></i></button>
|
||||
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ph-x ph-bold ph-lg"></i></button>
|
||||
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ph-prohibit ph-bold ph-lg"></i></button>
|
||||
</div>
|
||||
|
|
|
@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSelect v-model="type" :class="$style.typeSelect">
|
||||
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
|
||||
<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
|
||||
<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
|
||||
<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
|
||||
<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
|
||||
<option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option>
|
||||
|
@ -51,6 +52,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
|
||||
</MkInput>
|
||||
|
||||
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
|
||||
<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -62,6 +67,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
|||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
import { rolesCache } from '@/cache.js';
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
|
@ -77,6 +83,8 @@ const props = defineProps<{
|
|||
|
||||
const v = ref(deepClone(props.modelValue));
|
||||
|
||||
const roles = await rolesCache.fetch();
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (JSON.stringify(props.modelValue) === JSON.stringify(v.value)) return;
|
||||
v.value = deepClone(props.modelValue);
|
||||
|
@ -92,6 +100,7 @@ const type = computed({
|
|||
if (t === 'and') v.value.values = [];
|
||||
if (t === 'or') v.value.values = [];
|
||||
if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
|
||||
if (t === 'roleAssignedTo') v.value.roleId = '';
|
||||
if (t === 'createdLessThan') v.value.sec = 86400;
|
||||
if (t === 'createdMoreThan') v.value.sec = 86400;
|
||||
if (t === 'followersLessThanOrEq') v.value.value = 10;
|
||||
|
|
|
@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<FromSlot>
|
||||
<template #label>{{ i18n.ts.defaultLike }}</template>
|
||||
<MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="defaultLike" :normal="true" :noStyle="true"/>
|
||||
<MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="defaultLike" :normal="true" :noStyle="true"/>
|
||||
<MkEmoji v-else :emoji="defaultLike" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/>
|
||||
<MkButton rounded :small="true" @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton>
|
||||
</FromSlot>
|
||||
|
|
|
@ -200,6 +200,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.mentionLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.mentionLimit.value }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.mentionLimit)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.mentionLimit.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.mentionLimit.value" :disabled="role.policies.mentionLimit.useDefault" type="number" :readonly="readonly">
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.mentionLimit.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>
|
||||
|
|
|
@ -67,6 +67,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>{{ policies.mentionLimit }}</template>
|
||||
<MkInput v-model="policies.mentionLimit" type="number">
|
||||
</MkInput>
|
||||
</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>
|
||||
|
|
|
@ -135,7 +135,7 @@ async function addRole() {
|
|||
const { canceled, result: role } = await os.select({
|
||||
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
|
||||
});
|
||||
if (canceled) return;
|
||||
if (canceled || role == null) return;
|
||||
|
||||
rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ const directNotesPagination = {
|
|||
function setFilter(ev) {
|
||||
const typeItems = notificationTypes.map(t => ({
|
||||
text: i18n.ts._notification._types[t],
|
||||
active: includeTypes.value && includeTypes.value.includes(t),
|
||||
active: (includeTypes.value && includeTypes.value.includes(t)) ?? false,
|
||||
action: () => {
|
||||
includeTypes.value = [t];
|
||||
},
|
||||
|
@ -63,7 +63,7 @@ function setFilter(ev) {
|
|||
action: () => {
|
||||
includeTypes.value = null;
|
||||
},
|
||||
}, { type: 'divider' }, ...typeItems] : typeItems;
|
||||
}, { type: 'divider' as const }, ...typeItems] : typeItems;
|
||||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ if (defaultStore.state.uploadFolder) {
|
|||
|
||||
function chooseUploadFolder() {
|
||||
os.selectDriveFolder(false).then(async folder => {
|
||||
defaultStore.set('uploadFolder', folder ? folder.id : null);
|
||||
defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null);
|
||||
os.success();
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
uploadFolder.value = await misskeyApi('drive/folders/show', {
|
||||
|
|
|
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<template #item="{element}">
|
||||
<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
|
||||
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
|
||||
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else :emoji="element" :normal="true"/>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<template #item="{element}">
|
||||
<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
|
||||
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
|
||||
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else :emoji="element" :normal="true"/>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<FromSlot>
|
||||
<template #label>{{ i18n.ts.defaultLike }}</template>
|
||||
<MkCustomEmoji v-if="like && like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="like" :normal="true" :noStyle="true"/>
|
||||
<MkCustomEmoji v-if="like && like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="like" :normal="true" :noStyle="true"/>
|
||||
<MkEmoji v-else-if="like && !like.startsWith(':')" :emoji="like" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/>
|
||||
<span v-else-if="!like">{{ i18n.ts.notSet }}</span>
|
||||
<div class="_buttons" style="padding-top: 8px;">
|
||||
|
@ -228,7 +228,7 @@ async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
|
|||
os.pickEmoji(getHTMLElement(ev), {
|
||||
showPinned: false,
|
||||
}).then(it => {
|
||||
const emoji = it as string;
|
||||
const emoji = it;
|
||||
if (!itemsRef.value.includes(emoji)) {
|
||||
itemsRef.value.push(emoji);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<option value="following" v-if="hasSender">{{ i18n.ts.following }}</option>
|
||||
<option value="follower" v-if="hasSender">{{ i18n.ts.followers }}</option>
|
||||
<option value="mutualFollow" v-if="hasSender">{{ i18n.ts.mutualFollow }}</option>
|
||||
<option value="followingOrFollower" v-if="hasSender">{{ i18n.ts.followingOrFollower }}</option>
|
||||
<option value="list" v-if="hasSender">{{ i18n.ts.userList }}</option>
|
||||
<option value="never">{{ i18n.ts.none }}</option>
|
||||
</MkSelect>
|
||||
|
|
|
@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
$i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following :
|
||||
$i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers :
|
||||
$i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow :
|
||||
$i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower :
|
||||
$i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList :
|
||||
i18n.ts.all
|
||||
}}
|
||||
|
@ -34,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormSection>
|
||||
<div class="_gaps_m">
|
||||
<FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink>
|
||||
<FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
|
@ -114,6 +116,17 @@ function testNotification(): void {
|
|||
misskeyApi('notifications/test-notification');
|
||||
}
|
||||
|
||||
async function flushNotification() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.resetAreYouSure,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('notifications/flush');
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
|
|
@ -208,6 +208,7 @@ async function saveNew(): Promise<void> {
|
|||
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: ts._preferencesBackups.inputName,
|
||||
default: '',
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
|
@ -383,6 +384,7 @@ async function rename(id: string): Promise<void> {
|
|||
|
||||
const { canceled: cancel1, result: name } = await os.inputText({
|
||||
title: ts._preferencesBackups.inputName,
|
||||
default: '',
|
||||
});
|
||||
if (cancel1 || profiles.value[id].name === name) return;
|
||||
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { UnicodeEmojiDef } from './emojilist.js';
|
||||
|
||||
export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple): boolean {
|
||||
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
|
||||
return !(emoji.localOnly && note.user.host !== me.host)
|
||||
export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
|
||||
if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする;
|
||||
if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能
|
||||
|
||||
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
|
||||
return !(emoji.localOnly && note.user.host !== me.host)
|
||||
&& !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))
|
||||
&& (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id)));
|
||||
}
|
||||
|
|
|
@ -20,6 +20,10 @@ export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
|
|||
category: unicodeEmojiCategories[x[2]],
|
||||
}));
|
||||
|
||||
const unicodeEmojisMap = new Map<string, UnicodeEmojiDef>(
|
||||
emojilist.map(x => [x.char, x]),
|
||||
);
|
||||
|
||||
const _indexByChar = new Map<string, number>();
|
||||
const _charGroupByCategory = new Map<string, string[]>();
|
||||
for (let i = 0; i < emojilist.length; i++) {
|
||||
|
@ -35,16 +39,29 @@ for (let i = 0; i < emojilist.length; i++) {
|
|||
|
||||
export const emojiCharByCategory = _charGroupByCategory;
|
||||
|
||||
export function getEmojiName(char: string): string | null {
|
||||
export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
|
||||
// Colorize it because emojilist.json assumes that
|
||||
const idx = _indexByChar.get(colorizeEmoji(char));
|
||||
if (idx == null) {
|
||||
return null;
|
||||
return unicodeEmojisMap.get(colorizeEmoji(char))
|
||||
// カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする
|
||||
?? unicodeEmojisMap.get(char)
|
||||
// それでも見つからない場合はそのまま返す(絵文字情報がjsonに無い場合、このフォールバックが無いとレンダリングに失敗する)
|
||||
?? char;
|
||||
}
|
||||
|
||||
export function getEmojiName(char: string): string {
|
||||
// Colorize it because emojilist.json assumes that
|
||||
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
|
||||
if (idx === undefined) {
|
||||
// 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い
|
||||
return char;
|
||||
} else {
|
||||
return emojilist[idx].name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* テキストスタイル絵文字(U+260Eなどの1文字で表現される絵文字)をカラースタイル絵文字に変換します(VS16:U+FE0Fを付与)。
|
||||
*/
|
||||
export function colorizeEmoji(char: string) {
|
||||
return char.length === 1 ? `${char}\uFE0F` : char;
|
||||
}
|
||||
|
|
|
@ -12,29 +12,37 @@ export type FormItem = {
|
|||
label?: string;
|
||||
type: 'string';
|
||||
default: string | null;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
multiline?: boolean;
|
||||
treatAsMfm?: boolean;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'number';
|
||||
default: number | null;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
step?: number;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'boolean';
|
||||
default: boolean | null;
|
||||
description?: string;
|
||||
hidden?: boolean;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'enum';
|
||||
default: string | null;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
enum: EnumItem[];
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'radio';
|
||||
default: unknown | null;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
options: {
|
||||
label: string;
|
||||
|
@ -44,9 +52,12 @@ export type FormItem = {
|
|||
label?: string;
|
||||
type: 'range';
|
||||
default: number | null;
|
||||
step: number;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
step?: number;
|
||||
min: number;
|
||||
max: number;
|
||||
textConverter?: (value: number) => string;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'object';
|
||||
|
@ -57,6 +68,10 @@ export type FormItem = {
|
|||
type: 'array';
|
||||
default: unknown[] | null;
|
||||
hidden: boolean;
|
||||
} | {
|
||||
type: 'button';
|
||||
content?: string;
|
||||
action: (ev: MouseEvent, v: any) => void;
|
||||
};
|
||||
|
||||
export type Form = Record<string, FormItem>;
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type EmojiDef = {
|
||||
emoji: string;
|
||||
name: string;
|
||||
|
|
|
@ -126,7 +126,7 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
|
|||
*/
|
||||
export function playMisskeySfx(operationType: OperationType) {
|
||||
const sound = defaultStore.state[`sound_${operationType}`];
|
||||
if (sound.type == null || !canPlay || !navigator.userActivation.hasBeenActive) return;
|
||||
if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return;
|
||||
|
||||
canPlay = false;
|
||||
playMisskeySfxFile(sound).finally(() => {
|
||||
|
|
|
@ -117,6 +117,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
|
|||
import XDirectColumn from '@/ui/deck/direct-column.vue';
|
||||
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
|
||||
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
|
||||
|
||||
|
@ -221,21 +222,19 @@ document.documentElement.style.scrollBehavior = 'auto';
|
|||
loadDeck();
|
||||
|
||||
function changeProfile(ev: MouseEvent) {
|
||||
const items = ref([{
|
||||
let items: MenuItem[] = [{
|
||||
text: deckStore.state.profile,
|
||||
active: true.valueOf,
|
||||
}]);
|
||||
active: true,
|
||||
action: () => {},
|
||||
}];
|
||||
getProfiles().then(profiles => {
|
||||
items.value = [{
|
||||
text: deckStore.state.profile,
|
||||
active: true.valueOf,
|
||||
}, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
|
||||
items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
|
||||
text: k,
|
||||
action: () => {
|
||||
deckStore.set('profile', k);
|
||||
unisonReload();
|
||||
},
|
||||
}))), { type: 'divider' }, {
|
||||
}))), { type: 'divider' as const }, {
|
||||
text: i18n.ts._deck.newProfile,
|
||||
icon: 'ph-plus ph-bold ph-lg',
|
||||
action: async () => {
|
||||
|
@ -248,9 +247,10 @@ function changeProfile(ev: MouseEvent) {
|
|||
deckStore.set('profile', name);
|
||||
unisonReload();
|
||||
},
|
||||
}];
|
||||
});
|
||||
}).then(() => {
|
||||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
});
|
||||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
|
|
|
@ -93,10 +93,10 @@ const fetch = () => {
|
|||
|
||||
const choose = () => {
|
||||
os.selectDriveFolder(false).then(folder => {
|
||||
if (folder == null) {
|
||||
if (folder[0] == null) {
|
||||
return;
|
||||
}
|
||||
widgetProps.folderId = folder.id;
|
||||
widgetProps.folderId = folder[0].id;
|
||||
save();
|
||||
fetch();
|
||||
});
|
||||
|
|
|
@ -7,28 +7,28 @@ import { assert, describe, test } from 'vitest';
|
|||
import { searchEmoji } from '@/scripts/search-emoji.js';
|
||||
|
||||
describe('emoji autocomplete', () => {
|
||||
test('名前の完全一致は名前の前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の完全一致は名前の前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
|
||||
test('名前の前方一致は名前の部分一致より優先される', async () => {
|
||||
const result = searchEmoji('baaa', [{ emoji: ':baaar:', name: 'baaar' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':baaar:');
|
||||
});
|
||||
test('名前の前方一致は名前の部分一致より優先される', async () => {
|
||||
const result = searchEmoji('baaa', [{ emoji: ':baaar:', name: 'baaar' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':baaar:');
|
||||
});
|
||||
|
||||
test('名前の完全一致はタグの完全一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の完全一致はタグの完全一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
|
||||
test('名前の前方一致はタグの前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の前方一致はタグの前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
|
||||
test('名前の部分一致はタグの部分一致より優先される', async () => {
|
||||
const result = searchEmoji('oooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の部分一致はタグの部分一致より優先される', async () => {
|
||||
const result = searchEmoji('oooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ import { onScrollBottom, onScrollTop } from '@/scripts/scroll.js';
|
|||
|
||||
describe('Scroll', () => {
|
||||
describe('onScrollTop', () => {
|
||||
/* 動作しない(happy-domのバグ?)
|
||||
test('Initial onScrollTop callback for connected elements', () => {
|
||||
const { document } = new Window();
|
||||
const div = document.createElement('div');
|
||||
|
@ -21,6 +22,7 @@ describe('Scroll', () => {
|
|||
|
||||
assert.ok(called);
|
||||
});
|
||||
*/
|
||||
|
||||
test('No onScrollTop callback for disconnected elements', () => {
|
||||
const { document } = new Window();
|
||||
|
@ -35,11 +37,11 @@ describe('Scroll', () => {
|
|||
});
|
||||
|
||||
describe('onScrollBottom', () => {
|
||||
/* 動作しない(happy-domのバグ?)
|
||||
test('Initial onScrollBottom callback for connected elements', () => {
|
||||
const { document } = new Window();
|
||||
const div = document.createElement('div');
|
||||
assert.strictEqual(div.scrollTop, 0);
|
||||
(div as any).scrollHeight = 100; // happy-dom has no scrollHeight
|
||||
|
||||
document.body.append(div);
|
||||
|
||||
|
@ -48,12 +50,12 @@ describe('Scroll', () => {
|
|||
|
||||
assert.ok(called);
|
||||
});
|
||||
*/
|
||||
|
||||
test('No onScrollBottom callback for disconnected elements', () => {
|
||||
const { document } = new Window();
|
||||
const div = document.createElement('div');
|
||||
assert.strictEqual(div.scrollTop, 0);
|
||||
(div as any).scrollHeight = 100; // happy-dom has no scrollHeight
|
||||
|
||||
let called = false;
|
||||
onScrollBottom(div as any as HTMLElement, () => called = true);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue