Merge remote-tracking branch 'misskey-dev/develop' into io
This commit is contained in:
@@ -35,12 +35,12 @@
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.2",
|
||||
"chart.js": "4.4.1",
|
||||
"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.9.6",
|
||||
"chromatic": "11.0.0",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "3.3.1",
|
||||
@@ -59,12 +59,12 @@
|
||||
"photoswipe": "5.4.3",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.12.0",
|
||||
"sanitize-html": "2.12.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"sass": "1.71.1",
|
||||
"shiki": "1.1.6",
|
||||
"shiki": "1.1.7",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.161.0",
|
||||
"three": "0.162.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.8",
|
||||
@@ -79,58 +79,58 @@
|
||||
"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.19",
|
||||
"@types/node": "20.11.24",
|
||||
"@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": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"@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",
|
||||
"acorn": "8.11.3",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.6.5",
|
||||
"eslint": "8.56.0",
|
||||
"cypress": "13.6.6",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-vue": "9.21.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.2.1",
|
||||
"msw": "2.2.2",
|
||||
"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: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
|
@@ -47,11 +47,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>
|
||||
@@ -74,7 +69,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
type Input = {
|
||||
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local' | 'textarea';
|
||||
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local' | 'textarea';
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
@@ -84,23 +79,18 @@ 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;
|
||||
text?: string;
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
input?: Input;
|
||||
select?: Select;
|
||||
icon?: string;
|
||||
@@ -117,13 +107,21 @@ const props = withDefaults(defineProps<{
|
||||
cancelText?: string;
|
||||
}>(), {
|
||||
type: 'info',
|
||||
title: undefined,
|
||||
text: undefined,
|
||||
input: undefined,
|
||||
select: undefined,
|
||||
icon: undefined,
|
||||
actions: undefined,
|
||||
showOkButton: true,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: true,
|
||||
okText: undefined,
|
||||
cancelText: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { canceled: boolean; result: any }): void;
|
||||
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
@@ -149,8 +147,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>
|
||||
|
@@ -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();
|
||||
|
@@ -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;
|
||||
}>();
|
||||
|
@@ -35,6 +35,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][];
|
||||
@@ -75,15 +76,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(() => {
|
||||
|
@@ -173,7 +173,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);
|
||||
@@ -466,6 +466,7 @@ function setVisibility() {
|
||||
isSilenced: $i.isSilenced,
|
||||
localOnly: localOnly.value,
|
||||
src: visibilityButton.value,
|
||||
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
|
||||
}, {
|
||||
changeVisibility: v => {
|
||||
visibility.value = v;
|
||||
|
@@ -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="ti ti-world"></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="ti ti-home"></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="ti ti-lock"></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;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
|
@@ -81,6 +81,7 @@ export const ROLE_POLICIES = [
|
||||
'canDeleteContent',
|
||||
'canUpdateAvatar',
|
||||
'canUpdateBanner',
|
||||
'mentionLimit',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
'inviteLimitCycle',
|
||||
|
@@ -9,7 +9,8 @@ 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 { defaultStore } from '@/store.js';
|
||||
@@ -29,15 +30,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 P,
|
||||
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;
|
||||
@@ -86,21 +87,21 @@ export const apiWithDialog = ((
|
||||
return promise;
|
||||
}) as typeof misskeyApi;
|
||||
|
||||
export function promiseDialog<T extends Promise<any>>(
|
||||
promise: T,
|
||||
onSuccess?: ((res: any) => void) | null,
|
||||
onFailure?: ((err: Error) => void) | null,
|
||||
export function promiseDialog<T>(
|
||||
promise: Promise<T>,
|
||||
onSuccess?: ((res: T) => void) | null,
|
||||
onFailure?: ((err: Misskey.api.APIError) => void) | null,
|
||||
text?: string,
|
||||
): T {
|
||||
): Promise<T> {
|
||||
const showing = ref(true);
|
||||
const success = ref(false);
|
||||
const result = ref(false);
|
||||
|
||||
promise.then(res => {
|
||||
if (onSuccess) {
|
||||
showing.value = false;
|
||||
onSuccess(res);
|
||||
} else {
|
||||
success.value = true;
|
||||
result.value = true;
|
||||
window.setTimeout(() => {
|
||||
showing.value = false;
|
||||
}, 1000);
|
||||
@@ -119,7 +120,7 @@ export function promiseDialog<T extends Promise<any>>(
|
||||
|
||||
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
|
||||
popup(MkWaitingDialog, {
|
||||
success: success,
|
||||
success: result,
|
||||
showing: showing,
|
||||
text: text,
|
||||
}, {}, 'closed');
|
||||
@@ -150,21 +151,37 @@ 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;
|
||||
const dispose = () => {
|
||||
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ?
|
||||
window.setTimeout(() => {
|
||||
popups.value = popups.value.filter(popup => popup.id !== id);
|
||||
popups.value = popups.value.filter(item => item.id !== id);
|
||||
}, 0);
|
||||
};
|
||||
const state = {
|
||||
@@ -201,9 +218,9 @@ export function alert(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, props, {
|
||||
done: result => {
|
||||
done: () => {
|
||||
resolve();
|
||||
},
|
||||
}, 'closed');
|
||||
@@ -217,7 +234,7 @@ export function confirm(props: {
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
}): Promise<{ canceled: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
...props,
|
||||
showCancelButton: true,
|
||||
@@ -241,10 +258,12 @@ export function actions<T extends {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
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 => ({
|
||||
@@ -263,6 +282,21 @@ export function actions<T extends {
|
||||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url' | 'textarea';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
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' | 'textarea';
|
||||
title?: string | null;
|
||||
@@ -272,18 +306,34 @@ export function inputText(props: {
|
||||
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' | 'textarea';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
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,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
input: {
|
||||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
@@ -295,24 +345,49 @@ export function inputText(props: {
|
||||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number;
|
||||
}>;
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
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 | null;
|
||||
text?: string | null;
|
||||
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,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
input: {
|
||||
type: 'number',
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
@@ -326,31 +401,35 @@ export function inputDate(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
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,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
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 });
|
||||
@@ -359,34 +438,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;
|
||||
} & ({
|
||||
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 | null;
|
||||
text?: string | null;
|
||||
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 | null;
|
||||
text?: string | null;
|
||||
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,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
select: {
|
||||
items: props.items,
|
||||
groupedItems: props.groupedItems,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
@@ -397,7 +495,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;
|
||||
@@ -412,7 +510,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,
|
||||
@@ -423,9 +521,13 @@ 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: undefined;
|
||||
} | {
|
||||
canceled: false; result: GetFormResultType<F>;
|
||||
}> {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
|
||||
done: result => {
|
||||
resolve(result);
|
||||
},
|
||||
@@ -434,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,
|
||||
@@ -447,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,
|
||||
@@ -461,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,
|
||||
@@ -493,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,26 +614,28 @@ type AwaitType<T> =
|
||||
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) {
|
||||
export async function openEmojiPicker(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerWindow>, initialTextarea: typeof activeTextarea) {
|
||||
if (openingEmojiPicker) return;
|
||||
|
||||
activeTextarea = initialTextarea;
|
||||
|
||||
const textareas = document.querySelectorAll('textarea, input');
|
||||
for (const textarea of Array.from(textareas)) {
|
||||
for (const textarea of Array.from(
|
||||
document.querySelectorAll('textarea, input'),
|
||||
)) {
|
||||
textarea.addEventListener('focus', () => {
|
||||
activeTextarea = textarea;
|
||||
activeTextarea = textarea as HTMLTextAreaElement | HTMLInputElement;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
for (const node of Array.from(record.addedNodes).filter(n => n instanceof HTMLElement) as HTMLElement[]) {
|
||||
for (const textarea of Array.from(
|
||||
node.querySelectorAll('textarea, input'),
|
||||
).filter(el => (el as HTMLTextAreaElement | HTMLInputElement).dataset.preventEmojiInsert == null)) {
|
||||
if (document.activeElement === textarea) activeTextarea = textarea as HTMLTextAreaElement | HTMLInputElement;
|
||||
textarea.addEventListener('focus', () => {
|
||||
activeTextarea = textarea;
|
||||
activeTextarea = textarea as HTMLTextAreaElement | HTMLInputElement;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -547,7 +651,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
|
||||
|
||||
openingEmojiPicker = await popup(MkEmojiPickerWindow, {
|
||||
src,
|
||||
pinnedEmojis: opts?.asReactionPicker ? defaultStore.reactiveState.reactions : defaultStore.reactiveState.pinnedEmojis,
|
||||
pinnedEmojis: opts.asReactionPicker ? defaultStore.reactiveState.reactions : defaultStore.reactiveState.pinnedEmojis,
|
||||
...opts,
|
||||
}, {
|
||||
chosen: emoji => {
|
||||
@@ -561,13 +665,13 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -589,9 +693,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,
|
||||
@@ -610,7 +714,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というプロパティを生やす影響で、
|
||||
@@ -629,17 +733,3 @@ export function post(props: Record<string, any> = {}): Promise<void> {
|
||||
}
|
||||
|
||||
export const deckGlobalEvents = new EventEmitter();
|
||||
|
||||
/*
|
||||
export function checkExistence(fileData: ArrayBuffer): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = new FormData();
|
||||
data.append('md5', getMD5(fileData));
|
||||
|
||||
api('drive/files/find-by-hash', {
|
||||
md5: getMD5(fileData)
|
||||
}).then(resp => {
|
||||
resolve(resp.length > 0 ? resp[0] : null);
|
||||
});
|
||||
});
|
||||
}*/
|
||||
|
@@ -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;
|
||||
|
@@ -280,6 +280,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>
|
||||
|
@@ -96,6 +96,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>
|
||||
|
@@ -178,7 +178,7 @@ async function addRole(type: boolean) {
|
||||
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;
|
||||
|
||||
if (type) rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
|
||||
else rolesThatCanNotBeUsedThisEmojiAsReaction.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);
|
||||
}
|
||||
|
||||
|
@@ -113,7 +113,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', {
|
||||
|
@@ -213,7 +213,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">{{ i18n.ts.following }}</option>
|
||||
<option value="follower">{{ i18n.ts.followers }}</option>
|
||||
<option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
|
||||
<option value="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option>
|
||||
<option value="list">{{ 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>
|
||||
@@ -113,6 +115,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(() => []);
|
||||
|
@@ -204,6 +204,7 @@ async function saveNew(): Promise<void> {
|
||||
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: ts._preferencesBackups.inputName,
|
||||
default: '',
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
@@ -372,6 +373,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;
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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([{
|
||||
const 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: 'ti ti-plus',
|
||||
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);
|
||||
|
Reference in New Issue
Block a user