summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2024-03-01 21:17:01 +0900
committerGitHub <noreply@github.com>2024-03-01 21:17:01 +0900
commit7e706ea6693781fb8973800bb3d0c91b5ab91cdf (patch)
tree8abd91edd288284cf3cc47949e749f881cfaff8e /packages/frontend/src
parentMerge pull request #13045 from misskey-dev/develop (diff)
parentNew translations ja-jp.yml (Chinese Traditional) (#13480) (diff)
downloadsharkey-7e706ea6693781fb8973800bb3d0c91b5ab91cdf.tar.gz
sharkey-7e706ea6693781fb8973800bb3d0c91b5ab91cdf.tar.bz2
sharkey-7e706ea6693781fb8973800bb3d0c91b5ab91cdf.zip
Merge pull request #13447 from misskey-dev/develop
Release: 2024.3.0
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/account.ts2
-rw-r--r--packages/frontend/src/boot/main-boot.ts12
-rw-r--r--packages/frontend/src/components/MkAutocomplete.vue96
-rw-r--r--packages/frontend/src/components/MkChart.vue2
-rw-r--r--packages/frontend/src/components/MkCode.core.vue2
-rw-r--r--packages/frontend/src/components/MkDialog.vue29
-rw-r--r--packages/frontend/src/components/MkDriveSelectDialog.vue8
-rw-r--r--packages/frontend/src/components/MkEmojiPicker.section.vue3
-rw-r--r--packages/frontend/src/components/MkEmojiPicker.vue86
-rw-r--r--packages/frontend/src/components/MkEmojiPickerDialog.vue4
-rw-r--r--packages/frontend/src/components/MkEmojiPickerWindow.vue49
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue56
-rw-r--r--packages/frontend/src/components/MkNotifications.vue5
-rw-r--r--packages/frontend/src/components/MkPostForm.vue3
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue4
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue11
-rw-r--r--packages/frontend/src/components/MkVisibilityPicker.vue7
-rw-r--r--packages/frontend/src/components/global/MkA.stories.impl.ts4
-rw-r--r--packages/frontend/src/components/global/MkTime.stories.impl.ts5
-rw-r--r--packages/frontend/src/const.ts1
-rw-r--r--packages/frontend/src/directives/user-preview.ts1
-rw-r--r--packages/frontend/src/instance.ts30
-rw-r--r--packages/frontend/src/local-storage.ts1
-rw-r--r--packages/frontend/src/os.ts302
-rw-r--r--packages/frontend/src/pages/admin/RolesEditorFormula.vue9
-rw-r--r--packages/frontend/src/pages/admin/bot-protection.vue2
-rw-r--r--packages/frontend/src/pages/admin/branding.vue2
-rw-r--r--packages/frontend/src/pages/admin/email-settings.vue2
-rw-r--r--packages/frontend/src/pages/admin/external-services.vue2
-rw-r--r--packages/frontend/src/pages/admin/instance-block.vue2
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue2
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue6
-rw-r--r--packages/frontend/src/pages/admin/modlog.vue2
-rw-r--r--packages/frontend/src/pages/admin/object-storage.vue2
-rw-r--r--packages/frontend/src/pages/admin/other-settings.vue2
-rw-r--r--packages/frontend/src/pages/admin/proxy-account.vue2
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue19
-rw-r--r--packages/frontend/src/pages/admin/roles.vue7
-rw-r--r--packages/frontend/src/pages/admin/security.vue4
-rw-r--r--packages/frontend/src/pages/admin/server-rules.vue2
-rw-r--r--packages/frontend/src/pages/admin/settings.vue2
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.game.vue120
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.vue56
-rw-r--r--packages/frontend/src/pages/emoji-edit-dialog.vue2
-rw-r--r--packages/frontend/src/pages/instance-info.vue12
-rw-r--r--packages/frontend/src/pages/notifications.vue4
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue19
-rw-r--r--packages/frontend/src/pages/settings/drive.vue2
-rw-r--r--packages/frontend/src/pages/settings/emoji-picker.vue2
-rw-r--r--packages/frontend/src/pages/settings/notifications.notification-config.vue1
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue13
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue2
-rw-r--r--packages/frontend/src/scripts/autocomplete.ts4
-rw-r--r--packages/frontend/src/scripts/check-reaction-permissions.ts6
-rw-r--r--packages/frontend/src/scripts/clear-cache.ts4
-rw-r--r--packages/frontend/src/scripts/emojilist.ts9
-rw-r--r--packages/frontend/src/scripts/form.ts17
-rw-r--r--packages/frontend/src/scripts/search-emoji.ts106
-rw-r--r--packages/frontend/src/scripts/sound.ts2
-rw-r--r--packages/frontend/src/style.scss33
-rw-r--r--packages/frontend/src/ui/deck.vue20
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetSlideshow.vue4
63 files changed, 697 insertions, 535 deletions
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index e606fe368c..7f20e0b1a2 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -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,
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index b19d45a35e..61f04678bf 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -11,7 +11,7 @@ import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js';
-import { fetchInstance, instance } from '@/instance.js';
+import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
@@ -235,12 +235,10 @@ export async function mainBoot() {
}
}
- fetchInstance().then(() => {
- const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
- if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
- popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
- }
- });
+ const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read');
+ if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') {
+ popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed');
+ }
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 412325bfee..cae6bc7111 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -57,18 +57,7 @@ import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
-
-type EmojiDef = {
- emoji: string;
- name: string;
- url: string;
- aliasOf?: string;
-} | {
- emoji: string;
- name: string;
- aliasOf?: string;
- isCustomEmoji?: true;
-};
+import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
const lib = emojilist.filter(x => x.category !== 'flags');
@@ -249,7 +238,7 @@ function exec() {
return;
}
- emojis.value = emojiAutoComplete(props.q, emojiDb.value);
+ emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS;
@@ -267,87 +256,6 @@ function exec() {
}
}
-type EmojiScore = { emoji: EmojiDef, score: number };
-
-function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
- if (!query) {
- return [];
- }
-
- const matched = new Map<string, EmojiScore>();
- // 完全一致(エイリアス込み)
- emojiDb.some(x => {
- if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
- matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
- }
- return matched.size === max;
- });
-
- // 前方一致(エイリアスなし)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name.startsWith(query) && !x.aliasOf) {
- matched.set(x.name, { emoji: x, score: query.length + 1 });
- }
- return matched.size === max;
- });
- }
-
- // 前方一致(エイリアス込み)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
- matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
- }
- return matched.size === max;
- });
- }
-
- // 部分一致(エイリアス込み)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
- matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
- }
- return matched.size === max;
- });
- }
-
- // 簡易あいまい検索(3文字以上)
- if (matched.size < max && query.length > 3) {
- const queryChars = [...query];
- const hitEmojis = new Map<string, EmojiScore>();
-
- for (const x of emojiDb) {
- // 文字列の位置を進めながら、クエリの文字を順番に探す
-
- let pos = 0;
- let hit = 0;
- for (const c of queryChars) {
- pos = x.name.indexOf(c, pos);
- if (pos <= -1) break;
- hit++;
- }
-
- // 半分以上の文字が含まれていればヒットとする
- if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
- hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
- }
- }
-
- // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分)
- [...hitEmojis.values()]
- .sort((x, y) => y.score - x.score)
- .slice(0, 6)
- .forEach(it => matched.set(it.emoji.name, it));
- }
-
- return [...matched.values()]
- .sort((x, y) => y.score - x.score)
- .slice(0, max)
- .map(it => it.emoji);
-}
-
function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
}
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index dd745c2140..04b6d2f29c 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -240,7 +240,7 @@ const render = () => {
},
external: externalTooltipHandler,
callbacks: {
- label: (item) => chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString(),
+ label: (item) => `${item.dataset.label}: ${chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString()}`,
},
},
zoom: props.detailed ? {
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index f993e983e4..872517b6aa 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -52,7 +52,7 @@ async function fetchLanguage(to: string): Promise<void> {
return bundle.id === language || bundle.aliases?.includes(language);
});
if (bundles.length > 0) {
- console.log(`Loading language: ${language}`);
+ if (_DEV_) console.log(`Loading language: ${language}`);
await highlighter.loadLanguage(bundles[0].import);
codeLang.value = language;
} else {
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 4b7584faaa..4577d37c08 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -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();
}
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue
index 77b5532f79..f1ecc27123 100644
--- a/packages/frontend/src/components/MkDriveSelectDialog.vue
+++ b/packages/frontend/src/components/MkDriveSelectDialog.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue
index 30ad2bcbbf..c295ab6bb7 100644
--- a/packages/frontend/src/components/MkEmojiPicker.section.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.section.vue
@@ -16,6 +16,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)"
>
@@ -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[];
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 366273118b..06243e5b04 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -14,6 +14,7 @@ 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)"
@@ -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="ti ti-clock 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);
+});
+const pinnedEmojisDef = computed(() => {
+ return pinned.value?.map(getDef);
+});
+
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): 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,6 +378,14 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef):
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
}
+function getDef(emoji: string) {
+ if (emoji.includes(':')) {
+ return customEmojisMap.get(emoji.replace(/:/g, ''))!;
+ } else {
+ return getUnicodeEmoji(emoji)!;
+ }
+}
+
/** @see MkEmojiPicker.section.vue */
function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement;
@@ -526,6 +550,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 +584,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 +711,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;
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
index 59f4b51522..adcea839ee 100644
--- a/packages/frontend/src/components/MkEmojiPickerDialog.vue
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -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();
diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue
deleted file mode 100644
index 6952943345..0000000000
--- a/packages/frontend/src/components/MkEmojiPickerWindow.vue
+++ /dev/null
@@ -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>
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 0d8734799c..deedc5badb 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -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;
}>();
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index a9f019dd9c..389987338d 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -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,17 +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(() => {
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 819f0f692c..e03faeaf55 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -172,7 +172,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);
@@ -461,6 +461,7 @@ function setVisibility() {
isSilenced: $i.isSilenced,
localOnly: localOnly.value,
src: visibilityButton.value,
+ ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
}, {
changeVisibility: v => {
visibility.value = v;
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 3f775bc6e2..95eb367318 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -152,12 +152,12 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
icon: 'ti ti-crop',
action: () : void => { crop(file); },
}] : [], {
+ type: 'divider',
+ }, {
text: i18n.ts.attachCancel,
icon: 'ti ti-circle-x',
action: () => { detachMedia(file.id); },
}, {
- type: 'divider',
- }, {
text: i18n.ts.deleteFile,
icon: 'ti ti-trash',
danger: true,
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 0dcd8b0ea2..c41811febe 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -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(':'));
diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue
index 3439a751a0..5ecd41bfdf 100644
--- a/packages/frontend/src/components/MkVisibilityPicker.vue
+++ b/packages/frontend/src/components/MkVisibilityPicker.vue
@@ -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;
}>(), {
});
diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts
index 9d57841f04..c1d8cf0ca6 100644
--- a/packages/frontend/src/components/global/MkA.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkA.stories.impl.ts
@@ -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',
diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts
index 2b4b1485fd..355c839113 100644
--- a/packages/frontend/src/components/global/MkTime.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts
@@ -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(8640000000000000);
+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,11 +49,12 @@ export const Empty = {
export const RelativeFuture = {
...Empty,
async play({ canvasElement }) {
- await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future);
+ await expect(canvasElement).toHaveTextContent(i18n.tsx._timeIn.years({ n: 1 })); // n (1) = future (2024) - now (2023)
},
args: {
...Empty.args,
time: future,
+ origin: now,
},
} satisfies StoryObj<typeof MkTime>;
export const AbsoluteFuture = {
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 0bac4d0b7c..9e41926a97 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -75,6 +75,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
+ 'mentionLimit',
'canInvite',
'inviteLimit',
'inviteLimitCycle',
diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts
index 0d6c330da1..7a008a4486 100644
--- a/packages/frontend/src/directives/user-preview.ts
+++ b/packages/frontend/src/directives/user-preview.ts
@@ -99,7 +99,6 @@ export class UserPreview {
this.el.removeEventListener('mouseover', this.onMouseover);
this.el.removeEventListener('mouseleave', this.onMouseleave);
this.el.removeEventListener('click', this.onClick);
- window.clearInterval(this.checkTimer);
}
}
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index 2056023692..4232cbcd78 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -11,13 +11,24 @@ import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERR
// TODO: 他のタブと永続化されたstateを同期
-const cached = miLocalStorage.getItem('instance');
+//#region loader
+const providedMetaEl = document.getElementById('misskey_meta');
+
+let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null;
+let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
+const providedMeta = providedMetaEl && providedMetaEl.textContent ? JSON.parse(providedMetaEl.textContent) : null;
+const providedAt = providedMetaEl && providedMetaEl.dataset.generatedAt ? parseInt(providedMetaEl.dataset.generatedAt) : 0;
+if (providedAt > cachedAt) {
+ miLocalStorage.setItem('instance', JSON.stringify(providedMeta));
+ miLocalStorage.setItem('instanceCachedAt', providedAt.toString());
+ cachedMeta = providedMeta;
+ cachedAt = providedAt;
+}
+//#endregion
// TODO: instanceをリアクティブにするかは再考の余地あり
-export const instance: Misskey.entities.MetaResponse = reactive(cached ? JSON.parse(cached) : {
- // TODO: set default values
-});
+export const instance: Misskey.entities.MetaResponse = reactive(cachedMeta ?? {});
export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL);
@@ -25,7 +36,15 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
-export async function fetchInstance() {
+export async function fetchInstance(force = false): Promise<void> {
+ if (!force) {
+ const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
+
+ if (Date.now() - cachedAt < 1000 * 60 * 60) {
+ return;
+ }
+ }
+
const meta = await misskeyApi('meta', {
detail: false,
});
@@ -35,4 +54,5 @@ export async function fetchInstance() {
}
miLocalStorage.setItem('instance', JSON.stringify(instance));
+ miLocalStorage.setItem('instanceCachedAt', Date.now().toString());
}
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 3de81c9bb9..8029bca68d 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -7,6 +7,7 @@ type Keys =
'v' |
'lastVersion' |
'instance' |
+ 'instanceCachedAt' |
'account' |
'accounts' |
'latestDonationInfoShownAt' |
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index a4fde6b701..c561e84a23 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -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);
@@ -149,14 +148,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;
@@ -197,12 +212,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');
@@ -211,12 +226,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,
@@ -237,13 +252,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 => ({
@@ -262,19 +279,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 | null;
+ default: string;
minLength?: number;
maxLength?: number;
-}): Promise<{ canceled: true; result: undefined; } | {
+}): 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 | 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,
@@ -282,7 +330,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,
},
@@ -294,16 +342,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 | null;
-}): Promise<{ canceled: true; result: undefined; } | {
+ 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 | 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,
@@ -311,7 +384,7 @@ export function inputNumber(props: {
type: 'number',
placeholder: props.placeholder,
autocomplete: props.autocomplete,
- default: props.default,
+ default: props.default ?? null,
},
}, {
done: result => {
@@ -322,34 +395,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 });
@@ -358,34 +435,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;
+ title?: string;
+ text?: string;
+ default: 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;
} | {
- groupedItems: {
- label: string;
- items: {
- value: C;
- text: string;
- }[];
+ 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;
+}): 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 => {
@@ -396,7 +492,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;
@@ -411,7 +507,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,
@@ -422,9 +518,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);
},
@@ -433,7 +529,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,
@@ -446,7 +542,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,
@@ -460,23 +556,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,
@@ -492,7 +588,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,
@@ -505,67 +601,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,
@@ -587,9 +629,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,
@@ -608,7 +650,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というプロパティを生やす影響で、
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index f4a8f44955..2f5b4c47d8 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -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;
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index e5e04fdeb8..73c5e1919f 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -142,7 +142,7 @@ function save() {
turnstileSiteKey: turnstileSiteKey.value,
turnstileSecretKey: turnstileSecretKey.value,
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
</script>
diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue
index 2b559f92c9..fe1b7c561d 100644
--- a/packages/frontend/src/pages/admin/branding.vue
+++ b/packages/frontend/src/pages/admin/branding.vue
@@ -169,7 +169,7 @@ function save() {
feedbackUrl: feedbackUrl.value === '' ? null : feedbackUrl.value,
manifestJsonOverride: manifestJsonOverride.value === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride.value)),
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
index 839b9bee16..4a858887f3 100644
--- a/packages/frontend/src/pages/admin/email-settings.vue
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -124,7 +124,7 @@ function save() {
smtpUser: smtpUser.value,
smtpPass: smtpPass.value,
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue
index ba3eb05e72..e0b82eb02e 100644
--- a/packages/frontend/src/pages/admin/external-services.vue
+++ b/packages/frontend/src/pages/admin/external-services.vue
@@ -61,7 +61,7 @@ function save() {
deeplAuthKey: deeplAuthKey.value,
deeplIsPro: deeplIsPro.value,
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue
index 5167b2e6b2..6b14bd42c2 100644
--- a/packages/frontend/src/pages/admin/instance-block.vue
+++ b/packages/frontend/src/pages/admin/instance-block.vue
@@ -50,7 +50,7 @@ function save() {
silencedHosts: silencedHosts.value.split('\n') || [],
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index d6cb1e39a7..9efb34ac9a 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -110,7 +110,7 @@ function save() {
hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'),
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index 21d68331cb..e33c882721 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -110,6 +110,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
+ <template v-else-if="log.type === 'updateRemoteInstanceNote'">
+ <div>{{ i18n.ts.user }}: {{ log.info.userId }}</div>
+ <div :class="$style.diff">
+ <CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
+ </div>
+ </template>
<details>
<summary>raw</summary>
diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue
index 5e251b8a6f..8590ee1651 100644
--- a/packages/frontend/src/pages/admin/modlog.vue
+++ b/packages/frontend/src/pages/admin/modlog.vue
@@ -54,8 +54,6 @@ const pagination = {
})),
};
-console.log(Misskey);
-
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue
index 4ff5ab09ca..5fddb715cd 100644
--- a/packages/frontend/src/pages/admin/object-storage.vue
+++ b/packages/frontend/src/pages/admin/object-storage.vue
@@ -143,7 +143,7 @@ function save() {
objectStorageSetPublicRead: objectStorageSetPublicRead.value,
objectStorageS3ForcePathStyle: objectStorageS3ForcePathStyle.value,
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue
index 651f0ef936..345cf333b5 100644
--- a/packages/frontend/src/pages/admin/other-settings.vue
+++ b/packages/frontend/src/pages/admin/other-settings.vue
@@ -73,7 +73,7 @@ function save() {
enableChartsForRemoteUser: enableChartsForRemoteUser.value,
enableChartsForFederatedInstances: enableChartsForFederatedInstances.value,
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue
index 02b506d13d..81db9f1da9 100644
--- a/packages/frontend/src/pages/admin/proxy-account.vue
+++ b/packages/frontend/src/pages/admin/proxy-account.vue
@@ -56,7 +56,7 @@ function save() {
os.apiWithDialog('admin/update-meta', {
proxyAccountId: proxyAccountId.value,
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index ad9df35dbf..eb8a59b34f 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -160,6 +160,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>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 496cb09664..9753d9f6cb 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -48,6 +48,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>
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index cadcf5a8cc..c4745978df 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -196,7 +196,7 @@ async function init() {
enableTruemailApi.value = meta.enableTruemailApi;
truemailInstance.value = meta.truemailInstance;
truemailAuthKey.value = meta.truemailAuthKey;
- bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || "";
+ bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || '';
}
function save() {
@@ -221,7 +221,7 @@ function save() {
truemailAuthKey: truemailAuthKey.value,
bannedEmailDomains: bannedEmailDomains.value.split('\n'),
}).then(() => {
- fetchInstance();
+ fetchInstance(true);
});
}
diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue
index 87318bccce..ff9b8d6299 100644
--- a/packages/frontend/src/pages/admin/server-rules.vue
+++ b/packages/frontend/src/pages/admin/server-rules.vue
@@ -58,7 +58,7 @@ const save = async () => {
await os.apiWithDialog('admin/update-meta', {
serverRules: serverRules.value,
});
- fetchInstance();
+ fetchInstance(true);
};
const remove = (index: number): void => {
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index c505d70aa9..9a198ee8a3 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -243,7 +243,7 @@ async function save(): void {
notesPerOneAd: notesPerOneAd.value,
});
- fetchInstance();
+ fetchInstance(true);
}
const headerTabs = computed(() => []);
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index d9881cebbf..eba5b92154 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -7,9 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div v-if="!gameLoaded" :class="$style.loadingScreen">
- <div>
- Loading...
- </div>
+ <div>{{ i18n.ts.loading }}<MkEllipsis/></div>
</div>
<!-- ↓に対してTransitionコンポーネントを使うと何故かkeyを指定していてもキャッシュが効かず様々なコンポーネントが都度再評価されてパフォーマンスが低下する -->
<div v-show="gameLoaded" class="_gaps_s">
@@ -32,18 +30,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</Transition>
<div :class="$style.header">
- <div :class="[$style.frame, $style.headerTitle]">
- <div :class="$style.frameInner">
- <b>BUBBLE GAME</b>
- <div>- {{ gameMode }} -</div>
+ <div class="_woodenFrame" :class="[$style.headerTitle]">
+ <div class="_woodenFrameInner">
+ <b>{{ i18n.ts.bubbleGame }}</b>
+ <div>- {{ gameMode.toUpperCase() }} -</div>
</div>
</div>
- <div :class="[$style.frame, $style.frameH]">
- <div :class="$style.frameInner">
- <MkButton inline small @click="hold">HOLD</MkButton>
+ <div class="_woodenFrame _woodenFrameH">
+ <div class="_woodenFrameInner">
+ <MkButton inline small @click="hold">{{ i18n.ts._bubbleGame.hold }}</MkButton>
<img v-if="holdingStock" :src="getTextureImageUrl(holdingStock.mono)" style="width: 32px; margin-left: 8px; vertical-align: bottom;"/>
</div>
- <div :class="[$style.frameInner, $style.stock]" style="text-align: center;">
+ <div class="_woodenFrameInner" :class="$style.stock" style="text-align: center;">
<TransitionGroup
:enterActiveClass="$style.transition_stock_enterActive"
:leaveActiveClass="$style.transition_stock_leaveActive"
@@ -90,58 +88,74 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel">
<div class="_gaps_s">
<img src="/client-assets/drop-and-fusion/gameover.png" style="width: 200px; max-width: 100%; display: block; margin: auto; margin-bottom: -5px;"/>
- <div>SCORE: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
- <div>MAX CHAIN: <MkNumber :value="maxCombo"/></div>
- <div v-if="gameMode === 'yen'">TOTAL EARNINGS: <b><MkNumber :value="yenTotal ?? score"/>円</b></div>
- <div v-if="gameMode === 'sweets'"><b>おにぎり<MkNumber :value="score / 130"/>個分</b></div>
+ <div>{{ i18n.ts._bubbleGame._score.score }}: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
+ <div>{{ i18n.ts._bubbleGame._score.maxChain }}: <MkNumber :value="maxCombo"/></div>
+ <div v-if="gameMode === 'yen'">
+ {{ i18n.ts._bubbleGame._score.scoreYen }}:
+ <I18n :src="i18n.ts._bubbleGame._score.yen" tag="b">
+ <template #yen><MkNumber :value="yenTotal ?? score"/></template>
+ </I18n>
+ </div>
+ <I18n v-if="gameMode === 'sweets'" :src="i18n.ts._bubbleGame._score.scoreSweets" tag="div">
+ <template #onigiriQtyWithUnit>
+ <I18n :src="i18n.ts._bubbleGame._score.estimatedQty" tag="b">
+ <template #qty><MkNumber :value="score / 130"/></template>
+ </I18n>
+ </template>
+ </I18n>
</div>
</div>
<div v-if="replaying" :class="$style.replayIndicator"><span :class="$style.replayIndicatorText"><i class="ti ti-player-play"></i> {{ i18n.ts.replaying }}</span></div>
</div>
- <div v-if="replaying" :class="$style.frame">
- <div :class="$style.frameInner">
+ <div v-if="replaying" class="_woodenFrame">
+ <div class="_woodenFrameInner">
<div style="background: #0004;">
<div style="height: 10px; background: var(--accent); will-change: width;" :style="{ width: `${(currentFrame / endedAtFrame) * 100}%` }"></div>
</div>
</div>
- <div :class="$style.frameInner">
+ <div class="_woodenFrameInner">
<div class="_buttonsCenter">
- <MkButton @click="endReplay"><i class="ti ti-player-stop"></i> END</MkButton>
+ <MkButton @click="endReplay"><i class="ti ti-player-stop"></i> {{ i18n.ts.endReplay }}</MkButton>
<MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ti ti-player-track-next"></i> x4</MkButton>
<MkButton :primary="replayPlaybackRate === 16" @click="replayPlaybackRate = replayPlaybackRate === 16 ? 1 : 16"><i class="ti ti-player-track-next"></i> x16</MkButton>
</div>
</div>
</div>
- <div v-if="isGameOver" :class="$style.frame">
- <div :class="$style.frameInner">
+ <div v-if="isGameOver" class="_woodenFrame">
+ <div class="_woodenFrameInner">
<div class="_buttonsCenter">
<MkButton primary rounded @click="backToTitle">{{ i18n.ts.backToTitle }}</MkButton>
<MkButton primary rounded @click="replay">{{ i18n.ts.showReplay }}</MkButton>
<MkButton primary rounded @click="share">{{ i18n.ts.share }}</MkButton>
- <MkButton rounded @click="exportLog">Copy replay data</MkButton>
+ <MkButton rounded @click="exportLog">{{ i18n.ts.copyReplayData }}</MkButton>
</div>
</div>
</div>
<div style="display: flex;">
- <div :class="$style.frame" style="flex: 1; margin-right: 10px;">
- <div :class="$style.frameInner">
- <div>SCORE: <b><MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</b></div>
- <div>HIGH SCORE: <b v-if="highScore"><MkNumber :value="highScore"/>{{ getScoreUnit(gameMode) }}</b><b v-else>-</b></div>
- <div v-if="gameMode === 'yen'">TOTAL EARNINGS: <b v-if="yenTotal"><MkNumber :value="yenTotal"/>円</b><b v-else>-</b></div>
+ <div class="_woodenFrame" style="flex: 1; margin-right: 10px;">
+ <div class="_woodenFrameInner">
+ <div>{{ i18n.ts._bubbleGame._score.score }}: <MkNumber :value="score"/>{{ getScoreUnit(gameMode) }}</div>
+ <div>{{ i18n.ts._bubbleGame._score.highScore }}: <b v-if="highScore"><MkNumber :value="highScore"/>{{ getScoreUnit(gameMode) }}</b><b v-else>-</b></div>
+ <div v-if="gameMode === 'yen'">
+ {{ i18n.ts._bubbleGame._score.scoreYen }}:
+ <I18n :src="i18n.ts._bubbleGame._score.yen" tag="b">
+ <template #yen><MkNumber :value="yenTotal ?? score"/></template>
+ </I18n>
+ </div>
</div>
</div>
- <div :class="[$style.frame]" style="margin-left: auto;">
- <div :class="$style.frameInner" style="text-align: center;">
+ <div class="_woodenFrame" style="margin-left: auto;">
+ <div class="_woodenFrameInner" style="text-align: center;">
<div @click="showConfig = !showConfig"><i class="ti ti-settings"></i></div>
</div>
</div>
</div>
- <div v-if="showConfig" :class="$style.frame">
- <div :class="$style.frameInner">
+ <div v-if="showConfig" class="_woodenFrame">
+ <div class="_woodenFrameInner">
<div class="_gaps">
<MkRange v-model="bgmVolume" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true" @dragEnded="(v) => updateSettings('bgmVolume', v)">
<template #label>BGM {{ i18n.ts.volume }}</template>
@@ -153,8 +167,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
- <div :class="$style.frame">
- <div :class="$style.frameInner">
+ <div class="_woodenFrame">
+ <div class="_woodenFrameInner">
<div>FUSION RECIPE</div>
<div>
<div v-for="(mono, i) in game.monoDefinitions.sort((a, b) => a.level - b.level)" :key="mono.id" style="display: inline-block;">
@@ -165,10 +179,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
- <div :class="$style.frame">
- <div :class="$style.frameInner">
- <MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">Surrender</MkButton>
- <MkButton v-else full @click="restart">Retry</MkButton>
+ <div class="_woodenFrame">
+ <div class="_woodenFrameInner">
+ <MkButton v-if="!isGameOver && !replaying" full danger @click="surrender">{{ i18n.ts.surrender }}</MkButton>
+ <MkButton v-else full @click="restart">{{ i18n.ts.gameRetry }}</MkButton>
</div>
</div>
</div>
@@ -1313,38 +1327,6 @@ definePageMetadata(() => ({
max-width: 100%;
}
-.frame {
- padding: 7px;
- background: #8C4F26;
- box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
- border-radius: 10px;
-}
-
-.frameH {
- display: flex;
- gap: 6px;
-}
-
-.frameInner {
- padding: 8px;
- margin-top: 8px;
- background: #F1E8DC;
- box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
- border-radius: 6px;
- color: #693410;
-
- &:first-child {
- margin-top: 0;
- }
-}
-
-.frameDivider {
- height: 0;
- border: none;
- border-top: 1px solid #693410;
- border-bottom: 1px solid #ce8a5c;
-}
-
.header {
position: relative;
z-index: 10;
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index 1b11457988..54352c9b0d 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -15,13 +15,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-if="!gameStarted" :contentMax="800">
<div :class="$style.root">
<div class="_gaps">
- <div :class="$style.frame" style="text-align: center;">
- <div :class="$style.frameInner">
+ <div class="_woodenFrame" style="text-align: center;">
+ <div class="_woodenFrameInner">
<img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/>
</div>
</div>
- <div :class="$style.frame" style="text-align: center;">
- <div :class="$style.frameInner">
+ <div class="_woodenFrame" style="text-align: center;">
+ <div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;">
<MkSelect v-model="gameMode">
<option value="normal">NORMAL</option>
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
</div>
</div>
- <div :class="$style.frameInner">
+ <div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;">
<div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
<MkSwitch v-model="mute">
@@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
- <div :class="$style.frame">
- <div :class="$style.frameInner">
+ <div class="_woodenFrame">
+ <div class="_woodenFrameInner">
<div class="_gaps_s" style="padding: 16px;">
- <div><b>{{ i18n.tsx.lastNDays({ n: 7 }) }} {{ i18n.ts.ranking }}</b> ({{ gameMode }})</div>
+ <div><b>{{ i18n.tsx.lastNDays({ n: 7 }) }} {{ i18n.ts.ranking }}</b> ({{ gameMode.toUpperCase() }})</div>
<div v-if="ranking" class="_gaps_s">
<div v-for="r in ranking" :key="r.id" :class="$style.rankingRecord">
<MkAvatar :link="true" style="width: 24px; height: 24px; margin-right: 4px;" :user="r.user"/>
@@ -57,8 +57,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
- <div :class="$style.frame">
- <div :class="$style.frameInner" style="padding: 16px;">
+ <div class="_woodenFrame">
+ <div class="_woodenFrameInner" style="padding: 16px;">
<div style="font-weight: bold;">{{ i18n.ts._bubbleGame.howToPlay }}</div>
<ol>
<li>{{ i18n.ts._bubbleGame._howToPlay.section1 }}</li>
@@ -67,8 +67,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</ol>
</div>
</div>
- <div :class="$style.frame">
- <div :class="$style.frameInner">
+ <div class="_woodenFrame">
+ <div class="_woodenFrameInner">
<div class="_gaps_s" style="padding: 16px;">
<div><b>Credit</b></div>
<div>
@@ -149,38 +149,6 @@ definePageMetadata(() => ({
}
}
-.frame {
- padding: 7px;
- background: #8C4F26;
- box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
- border-radius: 10px;
-}
-
-.frameH {
- display: flex;
- gap: 6px;
-}
-
-.frameInner {
- padding: 8px;
- margin-top: 8px;
- background: #F1E8DC;
- box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
- border-radius: 6px;
- color: #693410;
-
- &:first-child {
- margin-top: 0;
- }
-}
-
-.frameDivider {
- height: 0;
- border: none;
- border-top: 1px solid #693410;
- border-bottom: 1px solid #ce8a5c;
-}
-
.rankingRecord {
display: flex;
line-height: 24px;
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index 12e9416f72..16769ef360 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -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);
}
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 2f1557182a..cb7fe2866c 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -39,6 +39,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
+ <MkTextarea v-model="moderationNote" manualSave>
+ <template #label>{{ i18n.ts.moderationNote }}</template>
+ </MkTextarea>
</div>
</FormSection>
@@ -119,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref, computed } from 'vue';
+import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkChart from '@/components/MkChart.vue';
import MkObjectView from '@/components/MkObjectView.vue';
@@ -141,6 +144,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
import { dateString } from '@/filters/date.js';
+import MkTextarea from '@/components/MkTextarea.vue';
const props = defineProps<{
host: string;
@@ -155,6 +159,7 @@ const suspended = ref(false);
const isBlocked = ref(false);
const isSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
+const moderationNote = ref('');
const usersPagination = {
endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
@@ -167,6 +172,10 @@ const usersPagination = {
offsetMode: true,
};
+watch(moderationNote, async () => {
+ await misskeyApi('admin/federation/update-instance', { host: instance.value.host, moderationNote: moderationNote.value });
+});
+
async function fetch(): Promise<void> {
if (iAmAdmin) {
meta.value = await misskeyApi('admin/meta');
@@ -178,6 +187,7 @@ async function fetch(): Promise<void> {
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
+ moderationNote.value = instance.value?.moderationNote;
}
async function toggleBlock(): Promise<void> {
diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue
index 7db6fa5395..28f5838296 100644
--- a/packages/frontend/src/pages/notifications.vue
+++ b/packages/frontend/src/pages/notifications.vue
@@ -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);
}
diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue
index cf7cec6b5e..5259dfa29a 100644
--- a/packages/frontend/src/pages/reversi/game.board.vue
+++ b/packages/frontend/src/pages/reversi/game.board.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
- <div :class="$style.board">
+ <div class="_woodenFrame">
<div :class="$style.boardInner">
<div v-if="showBoardLabels" :class="$style.labelsX">
<span v-for="i in game.map[0].length" :key="i" :class="$style.labelsXLabel">{{ String.fromCharCode(64 + i) }}</span>
@@ -124,8 +124,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder>
<template #label>{{ i18n.ts.options }}</template>
<div class="_gaps_s" style="text-align: left;">
- <MkSwitch v-model="showBoardLabels">Show labels</MkSwitch>
- <MkSwitch v-model="useAvatarAsStone">useAvatarAsStone</MkSwitch>
+ <MkSwitch v-model="showBoardLabels">{{ i18n.ts._reversi.showBoardLabels }}</MkSwitch>
+ <MkSwitch v-model="useAvatarAsStone">{{ i18n.ts._reversi.useAvatarAsStone }}</MkSwitch>
</div>
</MkFolder>
@@ -248,7 +248,7 @@ if (game.value.isStarted && !game.value.isEnded) {
crc32: crc32.toString(),
}).then((res) => {
if (res.desynced) {
- console.log('resynced');
+ if (_DEV_) console.log('resynced');
restoreGame(res.game!);
}
});
@@ -500,17 +500,6 @@ $gap: 4px;
text-align: center;
}
-.board {
- width: 100%;
- box-sizing: border-box;
- margin: 0 auto;
-
- padding: 7px;
- background: #8C4F26;
- box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
- border-radius: 12px;
-}
-
.boardInner {
padding: 32px;
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index cd38f9850f..1919f80864 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -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', {
diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue
index 79969427ec..ce296ec183 100644
--- a/packages/frontend/src/pages/settings/emoji-picker.vue
+++ b/packages/frontend/src/pages/settings/emoji-picker.vue
@@ -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);
}
diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue
index d6aac63674..a36f036303 100644
--- a/packages/frontend/src/pages/settings/notifications.notification-config.vue
+++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue
@@ -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>
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index febcfa32ed..70db6a5109 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -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(() => []);
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 676159d1b5..942de19d82 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -203,6 +203,7 @@ async function saveNew(): Promise<void> {
const { canceled, result: name } = await os.inputText({
title: ts._preferencesBackups.inputName,
+ default: '',
});
if (canceled) return;
@@ -371,6 +372,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;
diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts
index fe515d81a1..9fc8f7843e 100644
--- a/packages/frontend/src/scripts/autocomplete.ts
+++ b/packages/frontend/src/scripts/autocomplete.ts
@@ -93,9 +93,11 @@ export class Autocomplete {
return;
}
+ const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
+
const isMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1;
- const isMfmParam = mfmParamIndex !== -1 && text.split(/\$\[[a-zA-Z]+/).pop()?.includes('.');
+ const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam?.includes(' ');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts
index c9d2a5bfc6..da704717c1 100644
--- a/packages/frontend/src/scripts/check-reaction-permissions.ts
+++ b/packages/frontend/src/scripts/check-reaction-permissions.ts
@@ -1,6 +1,10 @@
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 {
+export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef): boolean {
+ if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能
+
+ emoji = emoji as Misskey.entities.EmojiSimple;
const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [];
return !(emoji.localOnly && note.user.host !== me.host)
&& !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote'))
diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/scripts/clear-cache.ts
index f2db87c4fb..b20109ec72 100644
--- a/packages/frontend/src/scripts/clear-cache.ts
+++ b/packages/frontend/src/scripts/clear-cache.ts
@@ -2,14 +2,18 @@ import { unisonReload } from '@/scripts/unison-reload.js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
+import { fetchInstance } from '@/instance.js';
export async function clearCache() {
os.waiting();
+ miLocalStorage.removeItem('instance');
+ miLocalStorage.removeItem('instanceCachedAt');
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');
+ await fetchInstance(true);
await fetchCustomEmojis(true);
unisonReload();
}
diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts
index 54d45e025f..f2fbeae0ed 100644
--- a/packages/frontend/src/scripts/emojilist.ts
+++ b/packages/frontend/src/scripts/emojilist.ts
@@ -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,6 +39,11 @@ for (let i = 0; i < emojilist.length; i++) {
export const emojiCharByCategory = _charGroupByCategory;
+export function getUnicodeEmoji(char: string): UnicodeEmojiDef | null {
+ // Colorize it because emojilist.json assumes that
+ return unicodeEmojisMap.get(colorizeEmoji(char)) ?? null;
+}
+
export function getEmojiName(char: string): string | null {
// Colorize it because emojilist.json assumes that
const idx = _indexByChar.get(colorizeEmoji(char));
diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts
index 26a027f461..b0db404f28 100644
--- a/packages/frontend/src/scripts/form.ts
+++ b/packages/frontend/src/scripts/form.ts
@@ -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>;
diff --git a/packages/frontend/src/scripts/search-emoji.ts b/packages/frontend/src/scripts/search-emoji.ts
new file mode 100644
index 0000000000..371f69b9a7
--- /dev/null
+++ b/packages/frontend/src/scripts/search-emoji.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type EmojiDef = {
+ emoji: string;
+ name: string;
+ url: string;
+ aliasOf?: string;
+} | {
+ emoji: string;
+ name: string;
+ aliasOf?: string;
+ isCustomEmoji?: true;
+};
+type EmojiScore = { emoji: EmojiDef, score: number };
+
+export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
+ if (!query) {
+ return [];
+ }
+
+ const matched = new Map<string, EmojiScore>();
+ // 完全一致(エイリアスなし)
+ emojiDb.some(x => {
+ if (x.name === query && !x.aliasOf) {
+ matched.set(x.name, { emoji: x, score: query.length + 3 });
+ }
+ return matched.size === max;
+ });
+
+ // 完全一致(エイリアス込み)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 前方一致(エイリアスなし)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) {
+ matched.set(x.name, { emoji: x, score: query.length + 1 });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 前方一致(エイリアス込み)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 部分一致(エイリアス込み)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 簡易あいまい検索(3文字以上)
+ if (matched.size < max && query.length > 3) {
+ const queryChars = [...query];
+ const hitEmojis = new Map<string, EmojiScore>();
+
+ for (const x of emojiDb) {
+ // 文字列の位置を進めながら、クエリの文字を順番に探す
+
+ let pos = 0;
+ let hit = 0;
+ for (const c of queryChars) {
+ pos = x.name.indexOf(c, pos);
+ if (pos <= -1) break;
+ hit++;
+ }
+
+ // 半分以上の文字が含まれていればヒットとする
+ if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
+ hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
+ }
+ }
+
+ // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分)
+ [...hitEmojis.values()]
+ .sort((x, y) => y.score - x.score)
+ .slice(0, 6)
+ .forEach(it => matched.set(it.emoji.name, it));
+ }
+
+ return [...matched.values()]
+ .sort((x, y) => y.score - x.score)
+ .slice(0, max)
+ .map(it => it.emoji);
+}
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 9555579e0d..fcd59510df 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -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) return;
+ if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return;
canPlay = false;
playMisskeySfxFile(sound).finally(() => {
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index cbec377277..0951a7d98d 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -417,6 +417,39 @@ rt {
transition-timing-function: cubic-bezier(0,.5,.5,1);
}
+._woodenFrame {
+ padding: 7px;
+ background: #8C4F26;
+ box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c;
+ border-radius: 10px;
+
+ --bg: #F1E8DC;
+ --panel: #fff;
+ --fg: #693410;
+ --switchOffBg: rgba(0, 0, 0, 0.1);
+ --switchOffFg: rgb(255, 255, 255);
+ --switchOnBg: var(--accent);
+ --switchOnFg: rgb(255, 255, 255);
+}
+
+._woodenFrameH {
+ display: flex;
+ gap: 6px;
+}
+
+._woodenFrameInner {
+ padding: 8px;
+ margin-top: 8px;
+ background: var(--bg);
+ box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410;
+ border-radius: 6px;
+ color: var(--fg);
+
+ &:first-child {
+ margin-top: 0;
+ }
+}
+
._transition_zoom-enter-active, ._transition_zoom-leave-active {
transition: opacity 0.5s, transform 0.5s !important;
}
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 92d2e23d9b..bdb62dca15 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -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: '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() {
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 125c85130e..bd3b059497 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="column.channelId">
<div style="padding: 8px; text-align: center;">
- <MkButton primary gradate rounded inline @click="post"><i class="ti ti-pencil"></i></MkButton>
+ <MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton>
</div>
<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/>
</template>
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index 7a3671a240..b8efd3bda9 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -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();
});