diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-03-02 17:28:34 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-03-02 17:28:34 +0000 |
| commit | 23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0 (patch) | |
| tree | 0b9e79c2f18f4a206811561fa255f2510f60c175 /packages/frontend/src | |
| parent | merge: Add missing IMPORTANT_NOTES.md from Sharkey/OldJoinSharkey (!443) (diff) | |
| parent | merge: put back the readme (!447) (diff) | |
| download | sharkey-23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0.tar.gz sharkey-23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0.tar.bz2 sharkey-23f476dbf32ef9a2fc7d2ed7aab9ce706a2409d0.zip | |
Merge branch 'develop' into release/2024.3.1
Diffstat (limited to 'packages/frontend/src')
692 files changed, 16968 insertions, 5742 deletions
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index efb78fe447..875353f8a4 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/_dev_boot_.ts b/packages/frontend/src/_dev_boot_.ts index d419ade527..09495dece4 100644 --- a/packages/frontend/src/_dev_boot_.ts +++ b/packages/frontend/src/_dev_boot_.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 05008194f0..171826c9d8 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,7 +11,8 @@ import { miLocalStorage } from '@/local-storage.js'; import { MenuButton } from '@/types/menu.js'; import { del, get, set } from '@/scripts/idb-proxy.js'; import { apiUrl } from '@/config.js'; -import { waiting, api, popup, popupMenu, success, alert } from '@/os.js'; +import { waiting, popup, popupMenu, success, alert } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; // TODO: 他のタブと永続化されたstateを同期 @@ -23,9 +24,14 @@ const accountData = miLocalStorage.getItem('account'); // TODO: 外部からはreadonlyに export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; -export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true); export const iAmAdmin = $i != null && $i.isAdmin; +export function signinRequired() { + if ($i == null) throw new Error('signin required'); + return $i; +} + export let notesCount = $i == null ? 0 : $i.notesCount; export function incNotesCount() { notesCount++; @@ -246,7 +252,7 @@ export async function openAccountMenu(opts: { } const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); - const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + const accountsPromise = misskeyApi('users/show', { userIds: storedAccounts.map(x => x.id) }); function createItem(account: Misskey.entities.UserDetailed) { return { @@ -284,7 +290,7 @@ export async function openAccountMenu(opts: { text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, - }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + }, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { type: 'parent' as const, icon: 'ph-plus ph-bold ph-lg', text: i18n.ts.addAccount, diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 63f169d9ad..9694a5b627 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -22,6 +22,7 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; +import { setupRouter } from '@/router/definition.js'; export async function common(createVue: () => App<Element>) { console.info(`Sharkey v${version}`); @@ -59,12 +60,6 @@ export async function common(createVue: () => App<Element>) { }); } - const splash = document.getElementById('splash'); - // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) - if (splash) splash.addEventListener('transitionend', () => { - splash.remove(); - }); - let isClientUpdated = false; //#region クライアントが更新されたかチェック @@ -245,6 +240,8 @@ export async function common(createVue: () => App<Element>) { const app = createVue(); + setupRouter(app); + if (_DEV_) { app.config.performance = true; } @@ -290,5 +287,10 @@ function removeSplash() { if (splash) { splash.style.opacity = '0'; splash.style.pointerEvents = 'none'; + + // transitionendイベントが発火しない場合があるため + window.setTimeout(() => { + splash.remove(); + }, 1000); } } diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index cdc1d11ca2..fbb4baebdc 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -1,25 +1,26 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { createApp, markRaw, defineAsyncComponent } from 'vue'; +import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { common } from './common.js'; import { ui } from '@/config.js'; import { i18n } from '@/i18n.js'; -import { confirm, alert, post, popup, toast } from '@/os.js'; +import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/scripts/sound.js'; -import { $i, updateAccount, signout } from '@/account.js'; -import { defaultStore, ColdDeviceStorage } from '@/store.js'; +import { $i, signout, updateAccount } from '@/account.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'; import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; -import { mainRouter } from '@/router.js'; import { initializeSw } from '@/scripts/initialize-sw.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; +import { mainRouter } from '@/router/main.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( @@ -75,9 +76,23 @@ export async function mainBoot() { if (defaultStore.state.enableSeasonalScreenEffect) { const month = new Date().getMonth() + 1; - if (month === 12 || month === 1) { - const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; - new SnowfallEffect().render(); + if (defaultStore.state.hemisphere === 'S') { + // ▼南半球 + if (month === 7 || month === 8) { + const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + new SnowfallEffect({}).render(); + } + } else { + // ▼北半球 + if (month === 12 || month === 1) { + const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + new SnowfallEffect({}).render(); + } else if (month === 3 || month === 4) { + const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + new SakuraEffect({ + sakura: true, + }).render(); + } } } @@ -203,7 +218,7 @@ export async function mainBoot() { const lastUsedDate = parseInt(lastUsed, 10); // 二時間以上前なら if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { - toast(i18n.t('welcomeBackWithName', { + toast(i18n.tsx.welcomeBackWithName({ name: $i.name || $i.username, })); } @@ -218,6 +233,11 @@ export async function mainBoot() { } } + const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read'); + if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://activitypub.software/TransFem-org/Sharkey/') { + popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed'); + } + if ('Notification' in window) { // 許可を得ていなかったらリクエスト if (Notification.permission === 'default') { @@ -269,7 +289,7 @@ export async function mainBoot() { main.on('unreadAntenna', () => { updateAccount({ hasUnreadAntenna: true }); - sound.play('antenna'); + sound.playMisskeySfx('antenna'); }); main.on('readAllAnnouncements', () => { diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts index 92ee074afb..017457822b 100644 --- a/packages/frontend/src/boot/sub-boot.ts +++ b/packages/frontend/src/boot/sub-boot.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 25d2b3c15f..b286528de6 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -1,13 +1,13 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import * as Misskey from 'misskey-js'; import { Cache } from '@/scripts/cache.js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; -export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => api('clips/list')); -export const rolesCache = new Cache(1000 * 60 * 30, () => api('admin/roles/list')); -export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => api('users/lists/list')); -export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => api('antennas/list')); +export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list')); +export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list')); +export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list')); +export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list')); diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts index 77e7c84d5c..cf09c96fd4 100644 --- a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts @@ -1,12 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { abuseUserReport } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAbuseReport from './MkAbuseReport.vue'; @@ -44,9 +44,9 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => { - action('POST /api/admin/resolve-abuse-user-report')(await req.json()); - return res(ctx.json({})); + http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => { + action('POST /api/admin/resolve-abuse-user-report')(await request.json()); + return HttpResponse.json({}); }), ], }, diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 611c8a1782..0493e885b9 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div class="bcekxzvu _margin _panel"> <div class="target"> - <MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`"> + <MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'"> <MkAvatar class="avatar" :user="report.targetUser" indicator/> <div class="names"> <MkUserName class="name" :user="report.targetUser"/> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="report.comment"/> </div> <hr/> - <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link">@{{ report.reporter.username }}</MkA></div> + <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div> <div v-if="report.assignee"> {{ i18n.ts.moderator }}: <MkAcct :user="report.assignee"/> diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index dc842b3d1b..9df957f3ec 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -1,12 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; @@ -44,9 +44,9 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/report-abuse', async (req, res, ctx) => { - action('POST /api/users/report-abuse')(await req.json()); - return res(ctx.json({})); + http.post('/api/users/report-abuse', async ({ request }) => { + action('POST /api/users/report-abuse')(await request.json()); + return HttpResponse.json({}); }), ], }, diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index 6819630b74..f228df85a6 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - user: Misskey.entities.User; + user: Misskey.entities.UserDetailed; initialComment?: string; }>(); diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index 33c6c24631..f1cfdc157a 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index b11cf1c8a0..83283a7073 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ import * as Misskey from 'misskey-js'; import MkMention from './MkMention.vue'; import { i18n } from '@/i18n.js'; import { host as localHost } from '@/config.js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const user = ref<Misskey.entities.UserLite>(); @@ -25,7 +25,7 @@ const props = defineProps<{ movedTo: string; // user id }>(); -api('users/show', { userId: props.movedTo }).then(u => user.value = u); +misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts index 6d972467b1..7614da51da 100644 --- a/packages/frontend/src/components/MkAchievements.stories.impl.ts +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAchievements from './MkAchievements.vue'; @@ -39,8 +39,8 @@ export const Empty = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/achievements', (req, res, ctx) => { - return res(ctx.json([])); + http.post('/api/users/achievements', () => { + return HttpResponse.json([]); }), ], }, @@ -52,8 +52,8 @@ export const All = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/achievements', (req, res, ctx) => { - return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })))); + http.post('/api/users/achievements', () => { + return HttpResponse.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 }))); }), ], }, diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index cdd9cb87b1..8ec3ec0505 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> <div v-if="achievements" :class="$style.root"> - <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel"> + <div v-for="achievement in achievements" :key="achievement.name" :class="$style.achievement" class="_panel"> <div :class="$style.icon"> <div :class="[$style.iconFrame, { @@ -55,6 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref, computed } from 'vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; @@ -71,7 +72,7 @@ const achievements = ref<Misskey.entities.UsersAchievementsResponse | null>(null const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x))); function fetch() { - os.api('users/achievements', { userId: props.user.id }).then(res => { + misskeyApi('users/achievements', { userId: props.user.id }).then(res => { achievements.value = []; for (const t of ACHIEVEMENT_TYPES) { const a = res.find(x => x.name === t); @@ -120,8 +121,8 @@ onMounted(() => { .iconFrame { position: relative; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); padding: 6px; border-radius: var(--radius-full); box-sizing: border-box; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index f87ad30f9b..270ca40825 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index 0e252f7b1d..835efbd6cd 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 284ee8f3f8..4bf6125af5 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index 42cfb90f7c..ffa4e56f5f 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 4c6e3e693a..74d0e7214f 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; @@ -43,20 +44,20 @@ async function ok() { const confirm = await os.confirm({ type: 'question', title: i18n.ts._announcement.readConfirmTitle, - text: i18n.t('_announcement.readConfirmText', { title: props.announcement.title }), + text: i18n.tsx._announcement.readConfirmText({ title: props.announcement.title }), }); if (confirm.canceled) return; } - modal.value.close(); - os.api('i/read-announcement', { announcementId: props.announcement.id }); + modal.value?.close(); + misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); updateAccount({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } function onBgClick() { - rootEl.value.animate([{ + rootEl.value?.animate([{ offset: 0, transform: 'scale(1)', }, { diff --git a/packages/frontend/src/components/MkAsUi.stories.impl.ts b/packages/frontend/src/components/MkAsUi.stories.impl.ts index 564fa902ba..cf8d5483b9 100644 --- a/packages/frontend/src/components/MkAsUi.stories.impl.ts +++ b/packages/frontend/src/components/MkAsUi.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 8475233dfa..11f454daa2 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </div> - <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span> - <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text" @clickEv="c.onClickEv"/> + <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : undefined, fontWeight: c.bold ? 'bold' : undefined, color: c.color }">{{ c.text }}</span> + <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text ?? ''" @clickEv="c.onClickEv"/> <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton> <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }"> <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> @@ -20,19 +20,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkSwitch> - <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput"> + <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default ?? null" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkTextarea> - <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput"> + <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput"> + <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default ?? null" type="number" @update:modelValue="c.onInput"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onChange"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> @@ -42,8 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPostForm fixed :instant="true" - :initialText="c.form.text" - :initialCw="c.form.cw" + :initialText="c.form?.text" + :initialCw="c.form?.cw" /> </div> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> @@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> </template> </MkFolder> - <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align ?? null, backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> + <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align, backgroundColor: c.bgColor, color: c.fgColor, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <template v-for="child in c.children" :key="child"> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/> </template> @@ -68,7 +68,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { AsUiComponent } from '@/scripts/aiscript/ui.js'; +import { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; @@ -85,20 +85,32 @@ const props = withDefaults(defineProps<{ const c = props.component; function g(id) { - return props.components.find(x => x.value.id === id).value; + const v = props.components.find(x => x.value.id === id)?.value; + if (v) return v; + + return { + id: 'dummy', + type: 'root', + children: [], + } as AsUiRoot; } -const valueForSwitch = ref(c.default ?? false); +const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false); function onSwitchUpdate(v) { valueForSwitch.value = v; - if (c.onChange) c.onChange(v); + if ('onChange' in c && c.onChange) { + c.onChange(v as never); + } } function openPostForm() { + const form = (c as AsUiPostFormButton).form; + if (!form) return; + os.post({ - initialText: c.form.text, - initialCw: c.form.cw, + initialText: form.text, + initialCw: form.cw, instant: true, }); } diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index 969519386f..ec24b8c240 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -1,14 +1,13 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; @@ -99,11 +98,11 @@ export const User = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users/search-by-username-and-host', () => { + return HttpResponse.json([ userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'), userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'), - ])); + ]); }), ], }, @@ -132,12 +131,12 @@ export const Hashtag = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/hashtags/search', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/hashtags/search', () => { + return HttpResponse.json([ '気象警報注意報', '気象警報', '気象情報', - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 1f819cf601..8b665bfacd 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </ol> <ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> <li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> - <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/> + <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> <!-- eslint-disable-next-line vue/no-v-html --> <span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span> @@ -35,6 +35,11 @@ SPDX-License-Identifier: AGPL-3.0-only <span>{{ tag }}</span> </li> </ol> + <ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list"> + <li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown"> + <span>{{ param }}</span> + </li> + </ol> </div> </template> @@ -42,33 +47,23 @@ SPDX-License-Identifier: AGPL-3.0-only import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import sanitizeHtml from 'sanitize-html'; import contains from '@/scripts/contains.js'; -import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js'; +import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { emojilist, getEmojiName } from '@/scripts/emojilist.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { MFM_TAGS } from '@/const.js'; - -type EmojiDef = { - emoji: string; - name: string; - url: string; - aliasOf?: string; -} | { - emoji: string; - name: string; - aliasOf?: string; - isCustomEmoji?: true; -}; +import { MFM_TAGS, MFM_PARAMS } from '@/const.js'; +import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; const lib = emojilist.filter(x => x.category !== 'flags'); const emojiDb = computed(() => { //#region Unicode Emoji - const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; + const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({ emoji: x.char, @@ -82,7 +77,7 @@ const emojiDb = computed(() => { unicodeEmojiDB.push({ emoji: emoji, name: k, - aliasOf: getEmojiName(emoji)!, + aliasOf: getEmojiName(emoji), url: char2path(emoji), }); } @@ -129,7 +124,7 @@ export default { <script lang="ts" setup> const props = defineProps<{ type: string; - q: string | null; + q: any; textarea: HTMLTextAreaElement; close: () => void; x: number; @@ -150,6 +145,7 @@ const hashtags = ref<any[]>([]); const emojis = ref<(EmojiDef)[]>([]); const items = ref<Element[] | HTMLCollection>([]); const mfmTags = ref<string[]>([]); +const mfmParams = ref<string[]>([]); const select = ref(-1); const zIndex = os.claimZIndex('high'); @@ -201,7 +197,7 @@ function exec() { users.value = JSON.parse(cache); fetching.value = false; } else { - os.api('users/search-by-username-and-host', { + misskeyApi('users/search-by-username-and-host', { username: props.q, limit: 10, detail: false, @@ -224,7 +220,7 @@ function exec() { hashtags.value = hashtags; fetching.value = false; } else { - os.api('hashtags/search', { + misskeyApi('hashtags/search', { query: props.q, limit: 30, }).then(searchedHashtags => { @@ -242,7 +238,7 @@ function exec() { return; } - emojis.value = emojiAutoComplete(props.q.toLowerCase(), emojiDb.value); + emojis.value = searchEmoji(props.q.toLowerCase(), emojiDb.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; @@ -250,79 +246,14 @@ function exec() { } mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? '')); - } -} - -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.toLowerCase().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.toLowerCase().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.toLowerCase().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.toLowerCase().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 }); - } + } else if (props.type === 'mfmParam') { + if (props.q.params.at(-1) === '') { + mfmParams.value = MFM_PARAMS[props.q.tag] ?? []; + return; } - // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) - [...hitEmojis.values()] - .sort((x, y) => y.score - x.score) - .slice(0, 6) - .forEach(it => matched.set(it.emoji.name, it)); + mfmParams.value = MFM_PARAMS[props.q.tag].filter(param => param.startsWith(props.q.params.at(-1) ?? '')); } - - return [...matched.values()] - .sort((x, y) => y.score - x.score) - .slice(0, max) - .map(it => it.emoji); } function onMousedown(event: Event) { @@ -408,7 +339,7 @@ function applySelect() { function chooseUser() { props.close(); - os.selectUser().then(user => { + os.selectUser({ includeSelf: true }).then(user => { complete('user', user); props.textarea.focus(); }); diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts index d41b64695f..d2a4a9f03b 100644 --- a/packages/frontend/src/components/MkAvatars.stories.impl.ts +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAvatars from './MkAvatars.vue'; @@ -38,12 +38,12 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/show', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users/show', () => { + return HttpResponse.json([ userDetailed('17'), userDetailed('20'), userDetailed('18'), - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 5644a324cf..8236d0ddb9 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = withDefaults(defineProps<{ userIds: string[]; @@ -27,7 +27,7 @@ const props = withDefaults(defineProps<{ const users = ref<Misskey.entities.UserLite[]>([]); onMounted(async () => { - users.value = await os.api('users/show', { + users.value = await misskeyApi('users/show', { userIds: props.userIds, }) as unknown as Misskey.entities.UserLite[]; }); diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e852557b12..e8802e4f8f 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 9fcc49d3f0..c0f41b64d0 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-else class="_button" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" - :to="to" + :to="to ?? '#'" @mousedown="onMousedown" > <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div> @@ -131,6 +131,10 @@ function onMousedown(evt: MouseEvent): void { box-sizing: border-box; transition: background 0.1s ease; + &:hover { + text-decoration: none; + } + &:not(:disabled):hover { background: var(--buttonHoverBg); } diff --git a/packages/frontend/src/components/MkCaptcha.stories.impl.ts b/packages/frontend/src/components/MkCaptcha.stories.impl.ts index fb50e50b18..475257cc45 100644 --- a/packages/frontend/src/components/MkCaptcha.stories.impl.ts +++ b/packages/frontend/src/components/MkCaptcha.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 40bca11e64..c64bb47e77 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -1,19 +1,22 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> - <span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span> - <div ref="captchaEl"></div> + <span v-if="!available">Loading<MkEllipsis/></span> + <div v-if="props.provider == 'mcaptcha'"> + <div id="mcaptcha__widget-container" class="m-captcha-style"></div> + <div ref="captchaEl"></div> + </div> + <div v-else ref="captchaEl"></div> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vue'; +import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; // APIs provided by Captcha services export type Captcha = { @@ -26,7 +29,7 @@ export type Captcha = { getResponse(id: string): string; }; -export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile'; +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -39,6 +42,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; sitekey: string | null; // null will show error on request + instanceUrl?: string | null; modelValue?: string | null; }>(); @@ -55,6 +59,7 @@ const variable = computed(() => { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; case 'turnstile': return 'turnstile'; + case 'mcaptcha': return 'mcaptcha'; } }); @@ -65,6 +70,7 @@ const src = computed(() => { case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + case 'mcaptcha': return null; } }); @@ -72,9 +78,9 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); -if (loaded) { +if (loaded || props.provider === 'mcaptcha') { available.value = true; -} else { +} else if (src.value !== null) { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { async: true, id: scriptId.value, @@ -87,7 +93,7 @@ function reset() { if (captcha.value.reset) captcha.value.reset(); } -function requestRender() { +async function requestRender() { if (captcha.value.render && captchaEl.value instanceof Element) { captcha.value.render(captchaEl.value, { sitekey: props.sitekey, @@ -96,6 +102,15 @@ function requestRender() { 'expired-callback': callback, 'error-callback': callback, }); + } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { + const { default: Widget } = await import('@mcaptcha/vanilla-glue'); + // @ts-expect-error avoid typecheck error + new Widget({ + siteKey: { + instanceUrl: new URL(props.instanceUrl), + key: props.sitekey, + }, + }); } else { window.setTimeout(requestRender, 1); } @@ -105,14 +120,27 @@ function callback(response?: string) { emit('update:modelValue', typeof response === 'string' ? response : null); } +function onReceivedMessage(message: MessageEvent) { + if (message.data.token) { + if (props.instanceUrl && new URL(message.origin).host === new URL(props.instanceUrl).host) { + callback(message.data.token); + } + } +} + onMounted(() => { if (available.value) { + window.addEventListener('message', onReceivedMessage); requestRender(); } else { watch(available, requestRender); } }); +onUnmounted(() => { + window.removeEventListener('message', onReceivedMessage); +}); + onBeforeUnmount(() => { reset(); }); diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 4a58204b5b..07732d9205 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -44,12 +44,12 @@ async function onClick() { try { if (isFollowing.value) { - await os.api('channels/unfollow', { + await misskeyApi('channels/unfollow', { channelId: props.channel.id, }); isFollowing.value = false; } else { - await os.api('channels/follow', { + await misskeyApi('channels/follow', { channelId: props.channel.id, }); isFollowing.value = true; diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 83d4401d2e..2850ecca16 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index f870b0eef1..1bac59d6df 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </I18n> </div> <div> - <i class="ph-pencil ph-bold ph-lg"></i> + <i class="ph-pencil-simple ph-bold ph-lg"></i> <I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> <template #n> <b>{{ channel.notesCount }}</b> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index adb3c134ae..04b6d2f29c 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only */ import { onMounted, ref, shallowRef, watch, PropType } from 'vue'; import { Chart } from 'chart.js'; -import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; import { alpha } from '@/scripts/color.js'; import date from '@/filters/date.js'; +import bytes from '@/filters/bytes.js'; import { initChart } from '@/scripts/init-chart.js'; import { chartLegend } from '@/scripts/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; @@ -95,7 +95,7 @@ const getColor = (i) => { }; const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; let chartData: { series: { name: string; @@ -108,9 +108,10 @@ let chartData: { y: number; }[]; }[]; -} = null; + bytes?: boolean; +} | null = null; -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const fetching = ref(true); const getDate = (ago: number) => { @@ -132,6 +133,7 @@ const format = (arr) => { const { handler: externalTooltipHandler } = useChartTooltip(); const render = () => { + if (chartData == null || chartEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -188,7 +190,6 @@ const render = () => { stacked: props.stacked, offset: false, time: { - stepSize: 1, unit: props.span === 'day' ? 'month' : 'day', displayFormats: { day: 'M/d', @@ -198,6 +199,7 @@ const render = () => { grid: { }, ticks: { + stepSize: 1, display: props.detailed, maxRotation: 0, autoSkipPadding: 16, @@ -237,6 +239,9 @@ const render = () => { duration: 0, }, external: externalTooltipHandler, + callbacks: { + label: (item) => `${item.dataset.label}: ${chartData?.bytes ? bytes(item.parsed.y * 1000, 1) : item.parsed.y.toString()}`, + }, }, zoom: props.detailed ? { pan: { @@ -265,10 +270,9 @@ const render = () => { }, }, } : undefined, - gradient, }, }, - plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])], + plugins: [chartVLine(vLineColor), ...(props.detailed && legendEl.value ? [chartLegend(legendEl.value)] : [])], }); }; @@ -277,7 +281,7 @@ const exportData = () => { }; const fetchFederationChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/federation', { limit: props.limit, span: props.span }); return { series: [{ name: 'Received', @@ -327,7 +331,7 @@ const fetchFederationChart = async (): Promise<typeof chartData> => { }; const fetchApRequestChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/ap-request', { limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -349,7 +353,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => { }; const fetchNotesChart = async (type: string): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -396,7 +400,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { }; const fetchNotesTotalChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -415,7 +419,7 @@ const fetchNotesTotalChart = async (): Promise<typeof chartData> => { }; const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -443,7 +447,7 @@ const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { }; const fetchActiveUsersChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/active-users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Read & Write', @@ -495,7 +499,7 @@ const fetchActiveUsersChart = async (): Promise<typeof chartData> => { }; const fetchDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -531,7 +535,7 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { }; const fetchDriveFilesChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -566,7 +570,7 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', @@ -588,7 +592,7 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -603,7 +607,7 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -618,7 +622,7 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -641,7 +645,7 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -649,7 +653,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char type: 'area', color: '#008FFB', data: format(total - ? raw.drive.totalUsage + ? sum(raw.drive.incUsage) : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), ), }], @@ -657,7 +661,7 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -672,11 +676,11 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { - series: [...(props.args.withoutAll ? [] : [{ + series: [...(props.args?.withoutAll ? [] : [{ name: 'All', - type: 'line', + type: 'line' as const, data: format(sum(raw.inc, negate(raw.dec))), color: '#888888', }]), { @@ -704,7 +708,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserPvChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/pv', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Unique PV (user)', @@ -731,7 +735,7 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -746,7 +750,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -761,8 +765,9 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span }); return { + bytes: true, series: [{ name: 'Inc', type: 'area', @@ -806,6 +811,8 @@ const fetchAndRender = async () => { case 'per-user-following': return fetchPerUserFollowingChart(); case 'per-user-followers': return fetchPerUserFollowersChart(); case 'per-user-drive': return fetchPerUserDriveChart(); + + default: return null; } }; fetching.value = true; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index c265fe6e97..240c9c919e 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root"> <button v-for="item in items" class="_button item" :class="{ disabled: item.hidden }" @click="onClick(item)"> - <span class="box" :style="{ background: chart.config.type === 'line' ? item.strokeStyle?.toString() : item.fillStyle?.toString() }"></span> + <span class="box" :style="{ background: type === 'line' ? item.strokeStyle?.toString() : item.fillStyle?.toString() }"></span> {{ item.text }} </button> </div> @@ -16,25 +16,23 @@ SPDX-License-Identifier: AGPL-3.0-only import { shallowRef } from 'vue'; import { Chart, LegendItem } from 'chart.js'; -const props = defineProps({ -}); - const chart = shallowRef<Chart>(); +const type = shallowRef<string>(); const items = shallowRef<LegendItem[]>([]); function update(_chart: Chart, _items: LegendItem[]) { chart.value = _chart, items.value = _items; + if ('type' in _chart.config) type.value = _chart.config.type; } function onClick(item: LegendItem) { if (chart.value == null) return; - const { type } = chart.value.config; - if (type === 'pie' || type === 'doughnut') { + if (type.value === 'pie' || type.value === 'doughnut') { // Pie and doughnut charts only have a single dataset and visibility is per item - chart.value.toggleDataVisibility(item.index); + if (item.index != null) chart.value.toggleDataVisibility(item.index); } else { - chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); + if (item.datasetIndex != null) chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); } chart.value.update(); } diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue index c11f516e37..51081ede23 100644 --- a/packages/frontend/src/components/MkChartTooltip.vue +++ b/packages/frontend/src/components/MkChartTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 1e72319010..892ad31b09 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 2f6790fa49..c51ad4356d 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 579c72b186..f9aaf4eff3 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -1,18 +1,19 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <!-- eslint-disable vue/no-v-html --> <template> -<div :class="['codeBlockRoot', { 'codeEditor': codeEditor }]" v-html="html"></div> +<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div> </template> <script lang="ts" setup> import { ref, computed, watch } from 'vue'; -import { BUNDLED_LANGUAGES } from 'shiki'; -import type { Lang as ShikiLang } from 'shiki'; -import { getHighlighter } from '@/scripts/code-highlighter.js'; +import { bundledLanguagesInfo } from 'shiki'; +import type { BuiltinLanguage } from 'shiki'; +import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; +import { defaultStore } from '@/store.js'; const props = defineProps<{ code: string; @@ -21,25 +22,38 @@ const props = defineProps<{ }>(); const highlighter = await getHighlighter(); +const darkMode = defaultStore.reactiveState.darkMode; +const codeLang = ref<BuiltinLanguage | 'aiscript'>('js'); + +const [lightThemeName, darkThemeName] = await Promise.all([ + getTheme('light', true), + getTheme('dark', true), +]); -const codeLang = ref<ShikiLang | 'aiscript'>('js'); const html = computed(() => highlighter.codeToHtml(props.code, { lang: codeLang.value, - theme: 'dark-plus', + themes: { + fallback: 'dark-plus', + light: lightThemeName, + dark: darkThemeName, + }, + defaultColor: false, + cssVariablePrefix: '--shiki-', })); async function fetchLanguage(to: string): Promise<void> { - const language = to as ShikiLang; + const language = to as BuiltinLanguage; // Check for the loaded languages, and load the language if it's not loaded yet. if (!highlighter.getLoadedLanguages().includes(language)) { // Check if the language is supported by Shiki - const bundles = BUNDLED_LANGUAGES.filter((bundle) => { + const bundles = bundledLanguagesInfo.filter((bundle) => { // Languages are specified by their id, they can also have aliases (i. e. "js" and "javascript") return bundle.id === language || bundle.aliases?.includes(language); }); if (bundles.length > 0) { - await highlighter.loadLanguage(language); + if (_DEV_) console.log(`Loading language: ${language}`); + await highlighter.loadLanguage(bundles[0].import); codeLang.value = language; } else { codeLang.value = 'js'; @@ -57,12 +71,37 @@ watch(() => props.lang, (to) => { }, { immediate: true }); </script> -<style scoped lang="scss"> -.codeBlockRoot :deep(.shiki) { +<style module lang="scss"> +.codeBlockRoot :global(.shiki) > code { + counter-reset: step; + counter-increment: step 0; +} + +.codeBlockRoot :global(.shiki) > code > .line::before { + content: counter(step); + counter-increment: step; + width: 1rem; + margin-right: 1.5rem; + display: inline-block; + text-align: right; + color: rgba(115,138,148,.4) +} + +.codeBlockRoot :global(.shiki) { padding: 1em; margin: .5em 0; overflow: auto; border-radius: var(--radius-sm); + border: 1px solid var(--divider); + font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; + + color: var(--shiki-fallback); + background-color: var(--shiki-fallback-bg); + + & span { + color: var(--shiki-fallback); + background-color: var(--shiki-fallback-bg); + } & pre, & code { @@ -70,14 +109,35 @@ watch(() => props.lang, (to) => { } } +.light.codeBlockRoot :global(.shiki) { + color: var(--shiki-light); + background-color: var(--shiki-light-bg); + + & span { + color: var(--shiki-light); + background-color: var(--shiki-light-bg); + } +} + +.dark.codeBlockRoot :global(.shiki) { + color: var(--shiki-dark); + background-color: var(--shiki-dark-bg); + + & span { + color: var(--shiki-dark); + background-color: var(--shiki-dark-bg); + } +} + .codeBlockRoot.codeEditor { min-width: 100%; height: 100%; - & :deep(.shiki) { + & :global(.shiki) { padding: 12px; margin: 0; border-radius: var(--radius-sm); + border: none; min-height: 130px; pointer-events: none; min-width: calc(100% - 24px); @@ -89,6 +149,11 @@ watch(() => props.lang, (to) => { text-rendering: inherit; text-transform: inherit; white-space: pre; + + & span { + display: inline-block; + min-height: 1em; + } } } </style> diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index e0973b676a..acd2ea6f97 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -1,58 +1,72 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Suspense> - <template #fallback> - <MkLoading v-if="!inline ?? true"/> - </template> - <code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code> - <XCode v-else-if="show && lang" :code="code" :lang="lang"/> - <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> - <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> - <div :class="$style.codePlaceholderContainer"> - <div><i class="ph-code ph-bold ph-lg"></i> {{ i18n.ts.code }}</div> - <div>{{ i18n.ts.clickToShow }}</div> - </div> +<div :class="$style.codeBlockRoot"> + <button :class="$style.codeBlockCopyButton" class="_button" @click="copy"> + <i class="ph-copy ph-bold ph-lg"></i> </button> -</Suspense> + <Suspense> + <template #fallback> + <MkLoading /> + </template> + <XCode v-if="show && lang" :code="code" :lang="lang"/> + <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> + <div :class="$style.codePlaceholderContainer"> + <div><i class="ph-code ph-bold ph-lg"></i> {{ i18n.ts.code }}</div> + <div>{{ i18n.ts.clickToShow }}</div> + </div> + </button> + </Suspense> +</div> </template> <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; +import * as os from '@/os.js'; import MkLoading from '@/components/global/MkLoading.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import copyToClipboard from '@/scripts/copy-to-clipboard.js'; -defineProps<{ +const props = defineProps<{ code: string; lang?: string; - inline?: boolean; }>(); const show = ref(!defaultStore.state.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); + +function copy() { + copyToClipboard(props.code); + os.success(); +} </script> <style module lang="scss"> -.codeInlineRoot { - display: inline-block; - font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; - overflow-wrap: anywhere; - color: #D4D4D4; - background: #1E1E1E; - padding: .1em; - border-radius: .3em; +.codeBlockRoot { + position: relative; +} + +.codeBlockCopyButton { + position: absolute; + top: 8px; + right: 8px; + opacity: 0.5; + + &:hover { + opacity: 0.8; + } } .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - color: #D4D4D4; - background: #1E1E1E; + background: var(--bg); padding: 1em; margin: .5em 0; overflow: auto; @@ -77,8 +91,8 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')) border-radius: var(--radius-sm); padding: 24px; margin-top: 4px; - color: #D4D4D4; - background: #1E1E1E; + color: var(--fg); + background: var(--bg); } .codePlaceholderContainer { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 0ec69a69af..30e518f8f0 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.codeEditorScroller"> <textarea ref="inputEl" - v-model="vModel" + v-model="v" :class="[$style.textarea]" :disabled="disabled" :required="required" @@ -58,7 +58,6 @@ const emit = defineEmits<{ }>(); const { modelValue } = toRefs(props); -const vModel = ref<string>(modelValue.value ?? ''); const v = ref<string>(modelValue.value ?? ''); const focused = ref(false); const changed = ref(false); @@ -79,15 +78,14 @@ const onKeydown = (ev: KeyboardEvent) => { if (ev.code === 'Enter') { const pos = inputEl.value?.selectionStart ?? 0; - const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length; + const posEnd = inputEl.value?.selectionEnd ?? v.value.length; if (pos === posEnd) { - const lines = vModel.value.slice(0, pos).split('\n'); + const lines = v.value.slice(0, pos).split('\n'); const currentLine = lines[lines.length - 1]; const currentLineSpaces = currentLine.match(/^\s+/); const posDelta = currentLineSpaces ? currentLineSpaces[0].length : 0; ev.preventDefault(); - vModel.value = vModel.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + vModel.value.slice(pos); - v.value = vModel.value; + v.value = v.value.slice(0, pos) + '\n' + (currentLineSpaces ? currentLineSpaces[0] : '') + v.value.slice(pos); nextTick(() => { inputEl.value?.setSelectionRange(pos + 1 + posDelta, pos + 1 + posDelta); }); @@ -97,9 +95,8 @@ const onKeydown = (ev: KeyboardEvent) => { if (ev.key === 'Tab') { const pos = inputEl.value?.selectionStart ?? 0; - const posEnd = inputEl.value?.selectionEnd ?? vModel.value.length; - vModel.value = vModel.value.slice(0, pos) + '\t' + vModel.value.slice(posEnd); - v.value = vModel.value; + const posEnd = inputEl.value?.selectionEnd ?? v.value.length; + v.value = v.value.slice(0, pos) + '\t' + v.value.slice(posEnd); nextTick(() => { inputEl.value?.setSelectionRange(pos + 1, pos + 1); }); @@ -199,20 +196,23 @@ watch(v, newValue => { resize: none; text-align: left; color: transparent; - caret-color: rgb(225, 228, 232); + caret-color: var(--fg); background-color: transparent; border: 0; border-radius: var(--radius-sm); + box-sizing: border-box; outline: 0; min-width: calc(100% - 24px); height: 100%; padding: 12px; + // the +2.5 rem is because of the line numbers + padding-left: calc(12px + 2.5rem); line-height: 1.5em; font-size: 1em; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; } .textarea::selection { - color: #fff; + color: var(--bg); } </style> diff --git a/packages/frontend/src/components/MkCodeInline.vue b/packages/frontend/src/components/MkCodeInline.vue new file mode 100644 index 0000000000..6add80d1bc --- /dev/null +++ b/packages/frontend/src/components/MkCodeInline.vue @@ -0,0 +1,25 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<code :class="$style.root">{{ code }}</code> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + code: string; +}>(); +</script> + +<style module lang="scss"> +.root { + display: inline-block; + font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; + overflow-wrap: anywhere; + background: var(--bg); + padding: .1em; + border-radius: .3em; +} +</style> diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index 4f15e88951..99aa46d561 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,8 +41,8 @@ const { modelValue } = toRefs(props); const v = ref(modelValue.value); const inputEl = shallowRef<HTMLElement>(); -const onInput = (ev: KeyboardEvent) => { - emit('update:modelValue', v.value); +const onInput = () => { + emit('update:modelValue', v.value ?? ''); }; </script> diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 42c6cc1075..95188c335e 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index e29cf472f7..5ca3c77fb2 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,8 +44,8 @@ onMounted(() => { let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 - const width = rootEl.value.offsetWidth; - const height = rootEl.value.offsetHeight; + const width = rootEl.value!.offsetWidth; + const height = rootEl.value!.offsetHeight; if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; @@ -63,8 +63,10 @@ onMounted(() => { left = 0; } - rootEl.value.style.top = `${top}px`; - rootEl.value.style.left = `${left}px`; + if (rootEl.value) { + rootEl.value.style.top = `${top}px`; + rootEl.value.style.left = `${left}px`; + } document.body.addEventListener('mousedown', onMousedown); }); diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 0a1ddd3171..54f6f39c9d 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -63,18 +63,25 @@ const loading = ref(true); const ok = async () => { const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { - const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); + const croppedImage = await cropper?.getCropperImage(); + const croppedSection = await cropper?.getCropperSelection(); + + // 拡大率を計算し、(ほぼ)元の大きさに戻す + const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth; + const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate; + + const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); croppedCanvas?.toBlob(blob => { if (!blob) return; const formData = new FormData(); formData.append('file', blob); formData.append('name', `cropped_${props.file.name}`); formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); - formData.append('comment', props.file.comment ?? 'null'); + if (props.file.comment) { formData.append('comment', props.file.comment);} formData.append('i', $i!.token); - if (props.uploadFolder || props.uploadFolder === null) { - formData.append('folderId', props.uploadFolder ?? 'null'); - } else if (defaultStore.state.uploadFolder) { + if (props.uploadFolder) { + formData.append('folderId', props.uploadFolder); + } else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) { formData.append('folderId', defaultStore.state.uploadFolder); } diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue new file mode 100644 index 0000000000..84b5375a41 --- /dev/null +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -0,0 +1,104 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> + <MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')"> + <template #header>:{{ emoji.name }}:</template> + <template #default> + <MkSpacer> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <div :class="$style.emojiImgWrapper"> + <MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji> + </div> + <MkKeyValue :copy="`:${emoji.name}:`"> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ emoji.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.tags }}</template> + <template #value> + <div v-if="emoji.aliases.length === 0">{{ i18n.ts.none }}</div> + <div v-else :class="$style.aliases"> + <span v-for="alias in emoji.aliases" :key="alias" :class="$style.alias"> + {{ alias }} + </span> + </div> + </template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.category }}</template> + <template #value>{{ emoji.category ?? i18n.ts.none }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.sensitive }}</template> + <template #value>{{ emoji.isSensitive ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.localOnly }}</template> + <template #value>{{ emoji.localOnly ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.license }}</template> + <template #value><Mfm :text="emoji.license ?? i18n.ts.none" /></template> + </MkKeyValue> + <MkKeyValue :copy="emoji.url"> + <template #key>{{ i18n.ts.emojiUrl }}</template> + <template #value> + <MkLink :url="emoji.url" target="_blank">{{ emoji.url }}</MkLink> + </template> + </MkKeyValue> + </div> + </MkSpacer> + </template> + </MkModalWindow> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { defineProps, shallowRef } from 'vue'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkLink from './MkLink.vue'; +const props = defineProps<{ + emoji: Misskey.entities.EmojiDetailed, +}>(); +const emit = defineEmits<{ + (ev: 'ok', cropped: Misskey.entities.DriveFile): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const cancel = () => { + emit('cancel'); + dialogEl.value!.close(); +}; +</script> + +<style lang="scss" module> +.emojiImgWrapper { + max-width: 100%; + height: 40cqh; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--X5) 8px, var(--X5) 14px); + border-radius: var(--radius); + margin: auto; + overflow-y: hidden; +} + +.aliases { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.alias { + display: inline-block; + word-break: break-all; + padding: 3px 10px; + background-color: var(--X5); + border: solid 1px var(--divider); + border-radius: var(--radius); +} +</style> diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index 4a6d2dfba2..a2cb3185f4 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import { concat } from '@/scripts/array.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -17,22 +18,9 @@ import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ modelValue: boolean; text: string | null; - renote: Misskey.entities.Note | null; - files: Misskey.entities.DriveFile[]; - poll?: { - expiresAt: string | null; - multiple: boolean; - choices: { - isVoted: boolean; - text: string; - votes: number; - }[]; - } | { - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; - }; + renote?: Misskey.entities.Note | null; + files?: Misskey.entities.DriveFile[]; + poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null; }>(); const emit = defineEmits<{ @@ -41,9 +29,9 @@ const emit = defineEmits<{ const label = computed(() => { return concat([ - props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [], + props.text ? [i18n.tsx._cw.chars({ count: props.text.length })] : [], props.renote ? [i18n.ts.quote] : [], - props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [], + props.files && props.files.length !== 0 ? [i18n.tsx._cw.files({ count: props.files.length })] : [], props.poll != null ? [i18n.ts.poll] : [], ] as string[][]).join(' / '); }); diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index b45aef45ff..475fbcb397 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -46,7 +46,7 @@ export default defineComponent({ function getDateText(time: string) { const date = new Date(time).getDate(); const month = new Date(time).getMonth() + 1; - return i18n.t('monthAndDay', { + return i18n.tsx.monthAndDay({ month: month.toString(), day: date.toString(), }); @@ -118,34 +118,36 @@ export default defineComponent({ return children; }; - function onBeforeLeave(el: HTMLElement) { + function onBeforeLeave(element: Element) { + const el = element as HTMLElement; el.style.top = `${el.offsetTop}px`; el.style.left = `${el.offsetLeft}px`; } - function onLeaveCanceled(el: HTMLElement) { + function onLeaveCancelled(element: Element) { + const el = element as HTMLElement; el.style.top = ''; el.style.left = ''; } - return () => h( - defaultStore.state.animation ? TransitionGroup : 'div', - { - class: { - [$style['date-separated-list']]: true, - [$style['date-separated-list-nogap']]: props.noGap, - [$style['reversed']]: props.reversed, - [$style['direction-down']]: props.direction === 'down', - [$style['direction-up']]: props.direction === 'up', - }, - ...(defaultStore.state.animation ? { - name: 'list', - tag: 'div', - onBeforeLeave, - onLeaveCanceled, - } : {}), - }, - { default: renderChildren }); + // eslint-disable-next-line vue/no-setup-props-destructure + const classes = { + [$style['date-separated-list']]: true, + [$style['date-separated-list-nogap']]: props.noGap, + [$style['reversed']]: props.reversed, + [$style['direction-down']]: props.direction === 'down', + [$style['direction-up']]: props.direction === 'up', + }; + + return () => defaultStore.state.animation ? h(TransitionGroup, { + class: classes, + name: 'list', + tag: 'div', + onBeforeLeave, + onLeaveCancelled, + }, { default: renderChildren }) : h('div', { + class: classes, + }, { default: renderChildren }); }, }); </script> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 2c0f6a4d78..b81ebbbb11 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,19 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ph-lock ph-bold ph-lg"></i></template> <template #caption> - <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> - <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> + <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"/> + <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> <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; }>(); @@ -125,7 +115,7 @@ const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { if (props.input.minLength) { - if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { + if (inputValue.value == null || (inputValue.value as string).length < props.input.minLength) { return 'charactersBelow'; } } @@ -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/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts index 5d16c09bc5..e3391bcf7e 100644 --- a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts +++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue index dff6e7d4dd..2e2321e6ac 100644 --- a/packages/frontend/src/components/MkDigitalClock.vue +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index a77ff42f94..a2780ddfe9 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,6 +26,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLink target="_blank" url="https://ko-fi.com/transfem">{{ i18n.ts.learnMore }}</MkLink> </div> </div> + <div v-if="instance.donationUrl" :class="$style.text"> + <I18n :src="i18n.ts.pleaseDonateInstance" tag="span"> + <template #host> + {{ instance.name ?? host }} + </template> + </I18n> + <div style="margin-top: 0.2em;"> + <MkLink target="_blank" :url="instance.donationUrl">{{ i18n.ts.learnMore }}</MkLink> + </div> + </div> <div class="_buttons"> <MkButton @click="close">{{ i18n.ts.remindMeLater }}</MkButton> <MkButton @click="neverShow">{{ i18n.ts.neverShow }}</MkButton> diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 9969c10258..13a2a2126c 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -49,9 +49,9 @@ import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { useRouter } from '@/router.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index dcaaa72cf4..945f45c012 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -35,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { claimAchievement } from '@/scripts/achievements.js'; @@ -144,7 +145,7 @@ function onDrop(ev: DragEvent) { if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); emit('removeFile', file.id); - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, folderId: props.folder.id, }); @@ -160,7 +161,7 @@ function onDrop(ev: DragEvent) { if (folder.id === props.folder.id) return; emit('removeFolder', folder.id); - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: folder.id, parentId: props.folder.id, }).then(() => { @@ -204,7 +205,7 @@ function onDragend() { } function go() { - emit('move', props.folder.id); + emit('move', props.folder); } function rename() { @@ -214,7 +215,7 @@ function rename() { default: props.folder.name, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: props.folder.id, name: name, }); @@ -222,7 +223,7 @@ function rename() { } function deleteFolder() { - os.api('drive/folders/delete', { + misskeyApi('drive/folders/delete', { folderId: props.folder.id, }).then(() => { if (defaultStore.state.uploadFolder === props.folder.id) { diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index cac3c17c85..d78c215328 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -112,7 +112,7 @@ function onDrop(ev: DragEvent) { if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); emit('removeFile', file.id); - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, folderId: props.folder ? props.folder.id : null, }); @@ -126,7 +126,7 @@ function onDrop(ev: DragEvent) { // 移動先が自分自身ならreject if (props.folder && folder.id === props.folder.id) return; emit('removeFolder', folder.id); - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: folder.id, parentId: props.folder ? props.folder.id : null, }); diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 00bb0e6e2b..2990ea6861 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -82,8 +82,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton> </div> <div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty"> - <div v-if="draghover">{{ i18n.t('empty-draghover') }}</div> - <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</div> + <div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div> + <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div> <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div> </div> </div> @@ -98,10 +98,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; +import type { MenuItem } from '@/types/menu.js'; import XNavFolder from '@/components/MkDrive.navFolder.vue'; import XFolder from '@/components/MkDrive.folder.vue'; import XFile from '@/components/MkDrive.file.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -254,7 +256,7 @@ function onDrop(ev: DragEvent): any { const file = JSON.parse(driveFile); if (files.value.some(f => f.id === file.id)) return; removeFile(file.id); - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, folderId: folder.value ? folder.value.id : null, }); @@ -270,7 +272,7 @@ function onDrop(ev: DragEvent): any { if (folder.value && droppedFolder.id === folder.value.id) return false; if (folders.value.some(f => f.id === droppedFolder.id)) return false; removeFolder(droppedFolder.id); - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: droppedFolder.id, parentId: folder.value ? folder.value.id : null, }).then(() => { @@ -307,7 +309,7 @@ function urlUpload() { placeholder: i18n.ts.uploadFromUrlDescription, }).then(({ canceled, result: url }) => { if (canceled || !url) return; - os.api('drive/files/upload-from-url', { + misskeyApi('drive/files/upload-from-url', { url: url, folderId: folder.value ? folder.value.id : undefined, }); @@ -325,7 +327,7 @@ function createFolder() { placeholder: i18n.ts.folderName, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/folders/create', { + misskeyApi('drive/folders/create', { name: name, parentId: folder.value ? folder.value.id : undefined, }).then(createdFolder => { @@ -341,7 +343,7 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) { default: folderToRename.name, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/folders/update', { + misskeyApi('drive/folders/update', { folderId: folderToRename.id, name: name, }).then(updatedFolder => { @@ -352,7 +354,7 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) { } function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { - os.api('drive/folders/delete', { + misskeyApi('drive/folders/delete', { folderId: folderToDelete.id, }).then(() => { // 削除時に親フォルダに移動 @@ -426,7 +428,7 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } } -function move(target?: Misskey.entities.DriveFolder) { +function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); return; @@ -436,7 +438,7 @@ function move(target?: Misskey.entities.DriveFolder) { fetching.value = true; - os.api('drive/folders/show', { + misskeyApi('drive/folders/show', { folderId: target, }).then(folderToMove => { folder.value = folderToMove; @@ -535,7 +537,7 @@ async function fetch() { const foldersMax = 30; const filesMax = 30; - const foldersPromise = os.api('drive/folders', { + const foldersPromise = misskeyApi('drive/folders', { folderId: folder.value ? folder.value.id : null, limit: foldersMax + 1, }).then(fetchedFolders => { @@ -546,7 +548,7 @@ async function fetch() { return fetchedFolders; }); - const filesPromise = os.api('drive/files', { + const filesPromise = misskeyApi('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, limit: filesMax + 1, @@ -571,7 +573,7 @@ function fetchMoreFolders() { const max = 30; - os.api('drive/folders', { + misskeyApi('drive/folders', { folderId: folder.value ? folder.value.id : null, type: props.type, untilId: folders.value.at(-1)?.id, @@ -594,7 +596,7 @@ function fetchMoreFiles() { const max = 30; // ファイル一覧取得 - os.api('drive/files', { + misskeyApi('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, untilId: files.value.at(-1)?.id, @@ -612,7 +614,7 @@ function fetchMoreFiles() { } function getMenu() { - return [{ + const menu: MenuItem[] = [{ type: 'switch', text: i18n.ts.keepOriginalUploading, ref: keepOriginal, @@ -633,7 +635,7 @@ function getMenu() { }, folder.value ? { text: i18n.ts.renameFolder, icon: 'ph-textbox ph-bold ph-lg', - action: () => { renameFolder(folder.value); }, + action: () => { if (folder.value) renameFolder(folder.value); }, } : undefined, folder.value ? { text: i18n.ts.deleteFolder, icon: 'ph-trash ph-bold ph-lg', @@ -643,6 +645,8 @@ function getMenu() { icon: 'ph-folder ph-bold ph-lg-plus', action: () => { createFolder(); }, }]; + + return menu; } function showMenu(ev: MouseEvent) { diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 3063523791..2f1fef4ea6 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index e65f4dd403..f1ecc27123 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue index 72aa79b153..c0142ec76e 100644 --- a/packages/frontend/src/components/MkDriveWindow.vue +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index dabc12237a..a5839586b6 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,10 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only :key="emoji" :data-emoji="emoji" class="_button item" + :disabled="disabledEmojis?.value.includes(emoji)" @pointerenter="computeButtonTitle" @click="emit('chosen', emoji, $event)" > - <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> + <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true" :fallbackToImage="true"/> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> </button> </div> @@ -27,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- フォルダの中にはカスタム絵文字やフォルダがある --> <section v-else v-panel style="border-radius: var(--radius-sm); border-bottom: 0.5px solid var(--divider);"> <header class="_acrylic" @click="shown = !shown"> - <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> (<i class="ph-folder ph-bold ph-lg"></i>:{{ customEmojiTree.length }} <i class="ph-smiley-sticker ph-bold ph-lg ti-fw"></i>:{{ emojis.length }}) + <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> (<i class="ph-folder ph-bold ph-lg"></i>:{{ customEmojiTree?.length }} <i class="ph-smiley-sticker ph-bold ph-lg ti-fw"></i>:{{ emojis.length }}) </header> <div v-if="shown" style="padding-left: 9px;"> <MkEmojiPickerSection @@ -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)" > @@ -60,13 +62,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, Ref } from 'vue'; -import { i18n } from '../i18n.js'; import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js'; +import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; const props = defineProps<{ emojis: string[] | Ref<string[]>; + disabledEmojis?: Ref<string[]>; initialShown?: boolean; hasChildSection?: boolean; customEmojiTree?: CustomEmojiFolderTree[]; @@ -84,10 +87,10 @@ const shown = ref(!!props.initialShown); function computeButtonTitle(ev: MouseEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; - elm.title = getEmojiName(emoji) ?? emoji; + elm.title = getEmojiName(emoji); } -function nestedChosen(emoji: any, ev?: MouseEvent) { +function nestedChosen(emoji: any, ev: MouseEvent) { emit('chosen', emoji, ev); } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index b7e329d7c2..1219a29d85 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,11 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="emoji in searchResultCustom" :key="emoji.name" class="_button item" + :disabled="!canReact(emoji)" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > - <MkCustomEmoji class="emoji" :name="emoji.name"/> + <MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/> </button> </div> <div v-if="searchResultUnicode.length > 0" class="body"> @@ -36,19 +37,20 @@ SPDX-License-Identifier: AGPL-3.0-only </section> <div v-if="tab === 'index'" class="group index"> - <section v-if="showPinned && pinned.length > 0"> + <section v-if="showPinned && (pinned && pinned.length > 0)"> <div class="body"> <button - v-for="emoji in pinned" - :key="emoji" - :data-emoji="emoji" + v-for="emoji in pinnedEmojisDef" + :key="getKey(emoji)" + :data-emoji="getKey(emoji)" class="_button item" + :disabled="!canReact(emoji)" tabindex="0" @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > - <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> - <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> + <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/> </button> </div> </section> @@ -57,15 +59,16 @@ SPDX-License-Identifier: AGPL-3.0-only <header class="_acrylic"><i class="ph-clock ph-bold ph-lg ti-fw"></i> {{ i18n.ts.recentUsed }}</header> <div class="body"> <button - v-for="emoji in recentlyUsedEmojis" - :key="emoji" + v-for="emoji in recentlyUsedEmojisDef" + :key="getKey(emoji)" class="_button item" - :data-emoji="emoji" + :disabled="!canReact(emoji)" + :data-emoji="getKey(emoji)" @pointerenter="computeButtonTitle" @click="chosen(emoji, $event)" > - <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> - <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> + <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/> </button> </div> </section> @@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="child in customEmojiFolderRoot.children" :key="`custom:${child.value}`" :initialShown="false" - :emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))" + :emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))" + :disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))" :hasChildSection="child.children.length !== 0" :customEmojiTree="child.children" @chosen="chosen" @@ -109,6 +113,7 @@ import { unicodeEmojiCategories as categories, getEmojiName, CustomEmojiFolderTree, + getUnicodeEmoji, } from '@/scripts/emojilist.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; @@ -118,6 +123,7 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js'; import { $i } from '@/account.js'; +import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; const props = withDefaults(defineProps<{ showPinned?: boolean; @@ -126,6 +132,7 @@ const props = withDefaults(defineProps<{ asDrawer?: boolean; asWindow?: boolean; asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう + targetNote?: Misskey.entities.Note; }>(), { showPinned: true, }); @@ -144,6 +151,13 @@ const { recentlyUsedEmojis, } = defaultStore.reactiveState; +const recentlyUsedEmojisDef = computed(() => { + return recentlyUsedEmojis.value.map(getDef).filter(x => x != null); +}); +const pinnedEmojisDef = computed(() => { + return pinned.value?.map(getDef).filter(x => x != null); +}); + const pinned = computed(() => props.pinnedEmojis); const size = computed(() => emojiPickerScale.value); const width = computed(() => emojiPickerWidth.value); @@ -221,6 +235,19 @@ watch(q, () => { } } } else { + if (customEmojisMap.has(newQ)) { + matches.add(customEmojisMap.get(newQ)!); + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias === newQ)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + for (const emoji of emojis) { if (emoji.name.startsWith(newQ)) { matches.add(emoji); @@ -322,12 +349,16 @@ 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 { - return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); +function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean { + return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji); +} + +function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean { + return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category; } function focus() { @@ -347,11 +378,22 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; } +function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef { + if (emoji.includes(':')) { + // カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、 + // サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておく(undefinedを返すとエラーになるため) + const name = emoji.replaceAll(':', ''); + return customEmojisMap.get(name) ?? emoji; + } else { + return getUnicodeEmoji(emoji); + } +} + /** @see MkEmojiPicker.section.vue */ function computeButtonTitle(ev: MouseEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; - elm.title = getEmojiName(emoji) ?? emoji; + elm.title = getEmojiName(emoji); } function chosen(emoji: any, ev?: MouseEvent) { @@ -511,6 +553,18 @@ defineExpose({ width: auto; height: auto; min-width: 0; + + &:disabled { + cursor: not-allowed; + background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%); + opacity: 1; + + > .emoji { + filter: grayscale(1); + mix-blend-mode: exclusion; + opacity: 0.8; + } + } } } } @@ -533,6 +587,18 @@ defineExpose({ width: auto; height: auto; min-width: 0; + + &:disabled { + cursor: not-allowed; + background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%); + opacity: 1; + + > .emoji { + filter: grayscale(1); + mix-blend-mode: exclusion; + opacity: 0.8; + } + } } } } @@ -648,6 +714,18 @@ defineExpose({ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } + &:disabled { + cursor: not-allowed; + background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%); + opacity: 1; + + > .emoji { + filter: grayscale(1); + mix-blend-mode: exclusion; + opacity: 0.8; + } + } + > .emoji { height: 1.25em; vertical-align: -.25em; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 4068a79f08..c6b3896989 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :showPinned="showPinned" :pinnedEmojis="pinnedEmojis" :asReactionPicker="asReactionPicker" + :targetNote="targetNote" :asDrawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen" @@ -32,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { shallowRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; @@ -43,6 +45,7 @@ const props = withDefaults(defineProps<{ showPinned?: boolean; pinnedEmojis?: string[], asReactionPicker?: boolean; + targetNote?: Misskey.entities.Note; choseAndClose?: boolean; }>(), { manualShowing: null, @@ -53,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; }>(); @@ -61,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 1a2c55e785..0000000000 --- a/packages/frontend/src/components/MkEmojiPickerWindow.vue +++ /dev/null @@ -1,47 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -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" asWindow :class="$style.picker" @chosen="chosen"/> -</MkWindow> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import MkWindow from '@/components/MkWindow.vue'; -import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; - -withDefaults(defineProps<{ - src?: HTMLElement; - showPinned?: boolean; - asReactionPicker?: boolean; -}>(), { - 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/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue index 6d1bad7433..8d875790bc 100644 --- a/packages/frontend/src/components/MkFeaturedPhotos.vue +++ b/packages/frontend/src/components/MkFeaturedPhotos.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const meta = ref<Misskey.entities.MetaResponse>(); -os.api('meta', { detail: true }).then(gotMeta => { +misskeyApi('meta', { detail: true }).then(gotMeta => { meta.value = gotMeta; }); </script> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index b799fb9447..39551e6b3c 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withOkButton="true" :okButtonDisabled="false" @ok="ok()" - @close="dialog.close()" + @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.describeFile }}</template> @@ -48,6 +48,6 @@ const caption = ref(props.default); async function ok() { emit('done', caption.value); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index eb0d4d61ac..f3305e9f54 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> <MkA - v-for="file in items" + v-for="file in (items as Misskey.entities.DriveFile[])" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`" :to="`/admin/file/${file.id}`" diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index ab435585d9..c5dd877971 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only <header> <h1 :title="flash.title">{{ flash.title }}</h1> </header> - <p v-if="flash.summary" :title="flash.summary">{{ flash.summary.length > 85 ? flash.summary.slice(0, 85) + '…' : flash.summary }}</p> + <p v-if="flash.summary" :title="flash.summary"> + <Mfm class="summaryMfm" :text="flash.summary" :plain="true" :nowrap="true"/> + </p> <footer> <img class="icon" :src="flash.user.avatarUrl"/> <p>{{ userName(flash.user) }}</p> @@ -54,6 +56,12 @@ const props = defineProps<{ margin: 0; color: var(--urlPreviewText); font-size: 0.8em; + overflow: clip; + + > .summaryMfm { + display: block; + width: 100%; + } } > footer { diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 65afc48f06..51bcafd1c2 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="el" :class="$style.root"> +<div ref="rootEl" :class="$style.root"> <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody"> <div :class="$style.title"><div><slot name="header"></slot></div></div> <div :class="$style.divider"></div> @@ -14,7 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </header> <Transition - :name="defaultStore.state.animation ? 'folder-toggle' : ''" + :enterActiveClass="defaultStore.state.animation ? $style.folderToggleEnterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.folderToggleLeaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.folderToggleEnterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.folderToggleLeaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -42,8 +45,8 @@ const props = withDefaults(defineProps<{ expanded: true, }); -const el = shallowRef<HTMLDivElement>(); -const bg = ref<string | null>(null); +const rootEl = shallowRef<HTMLDivElement>(); +const bg = ref<string>(); const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); watch(showBody, () => { @@ -52,40 +55,44 @@ watch(showBody, () => { } }); -function enter(el: Element) { +function enter(element: Element) { + const el = element as HTMLElement; const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; + el.style.height = '0'; el.offsetHeight; // reflow el.style.height = elementHeight + 'px'; } -function afterEnter(el: Element) { - el.style.height = null; +function afterEnter(element: Element) { + const el = element as HTMLElement; + el.style.height = 'unset'; } -function leave(el: Element) { +function leave(element: Element) { + const el = element as HTMLElement; const elementHeight = el.getBoundingClientRect().height; el.style.height = elementHeight + 'px'; el.offsetHeight; // reflow - el.style.height = 0; + el.style.height = '0'; } -function afterLeave(el: Element) { - el.style.height = null; +function afterLeave(element: Element) { + const el = element as HTMLElement; + el.style.height = 'unset'; } onMounted(() => { - function getParentBg(el: HTMLElement | null): string { + function getParentBg(el?: HTMLElement | null): string { if (el == null || el.tagName === 'BODY') return 'var(--bg)'; - const bg = el.style.background || el.style.backgroundColor; - if (bg) { - return bg; + const background = el.style.background || el.style.backgroundColor; + if (background) { + return background; } else { return getParentBg(el.parentElement); } } - const rawBg = getParentBg(el.value); + const rawBg = getParentBg(rootEl.value); const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); _bg.setAlpha(0.85); bg.value = _bg.toRgbString(); @@ -93,14 +100,12 @@ onMounted(() => { </script> <style lang="scss" module> -.folder-toggle-enter-active, .folder-toggle-leave-active { +.folderToggleEnterActive, .folderToggleLeaveActive { overflow-y: clip; transition: opacity 0.5s, height 0.5s !important; } -.folder-toggle-enter-from { - opacity: 0; -} -.folder-toggle-leave-to { + +.folderToggleEnterFrom, .folderToggleLeaveTo { opacity: 0; } diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 03621a4255..64d390f52b 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened"> + <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" @@ -109,7 +109,7 @@ function toggle() { onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); - const parentBg = getBgColor(rootEl.value.parentElement); + const parentBg = getBgColor(rootEl.value!.parentElement!); const myBg = computedStyle.getPropertyValue('--panel'); bgSame.value = parentBg === myBg; }); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index d1b1956a03..d0e8750e6a 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,11 +38,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { onBeforeUnmount, onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { $i } from '@/account.js'; -import { defaultStore } from "@/store.js"; +import { defaultStore } from '@/store.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -63,7 +64,7 @@ const wait = ref(false); const connection = useStream().useChannel('main'); if (props.user.isFollowing == null) { - os.api('users/show', { + misskeyApi('users/show', { userId: props.user.id, }) .then(onFollowChange); @@ -83,22 +84,22 @@ async function onClick() { if (isFollowing.value) { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), + text: i18n.tsx.unfollowConfirm({ name: props.user.name || props.user.username }), }); if (canceled) return; - await os.api('following/delete', { + await misskeyApi('following/delete', { userId: props.user.id, }); } else { if (hasPendingFollowRequestFromYou.value) { - await os.api('following/requests/cancel', { + await misskeyApi('following/requests/cancel', { userId: props.user.id, }); hasPendingFollowRequestFromYou.value = false; } else { - await os.api('following/create', { + await misskeyApi('following/create', { userId: props.user.id, withReplies: defaultStore.state.defaultWithReplies, }); diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 9b57688a02..35112ad45d 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="370" :height="400" - @close="dialog.close()" + @close="dialog?.close()" @closed="emit('closed')" > <template #header>{{ i18n.ts.forgotPassword }}</template> @@ -66,6 +66,6 @@ async function onSubmit() { email: email.value, }); emit('done'); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 6f882cfab7..deedc5badb 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,41 +20,45 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <MkSpacer :marginMin="20" :marginMax="32"> - <div 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> + <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> + <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="item in form[item].enum" :key="item.value" :value="item.value">{{ item.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="item in form[item].options" :key="item.value" :value="item.value">{{ item.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> + <div v-else class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> </MkSpacer> </MkModalWindow> </template> @@ -68,19 +72,23 @@ 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; }>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); @@ -94,13 +102,13 @@ function ok() { emit('done', { result: values, }); - dialog.value.close(); + dialog.value?.close(); } function cancel() { emit('done', { canceled: true, }); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index 035b727a35..a433ad680b 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { galleryPost } from '../../.storybook/fakes.js'; import MkGalleryPostPreview from './MkGalleryPostPreview.vue'; diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 316632b1a6..47cccd9b7c 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only leaveActiveClass: $style.transition_toggle_leaveActive, leaveToClass: $style.transition_toggle_leaveTo, }" - :src="post.files[0].thumbnailUrl" - :hash="post.files[0].blurhash" + :src="post.files?.[0]?.thumbnailUrl" + :hash="post.files?.[0]?.blurhash" :forceBlurhash="!show" /> </Transition> diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue index c0b20507fc..c92a49d32a 100644 --- a/packages/frontend/src/components/MkGoogle.vue +++ b/packages/frontend/src/components/MkGoogle.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index a57e6c9292..0cc0df9911 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import * as os from '@/os.js'; +import * as Misskey from 'misskey-js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { alpha } from '@/scripts/color.js'; @@ -23,14 +24,21 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const props = defineProps<{ - src: string; -}>(); +export type HeatmapSource = 'active-users' | 'notes' | 'ap-requests-inbox-received' | 'ap-requests-deliver-succeeded' | 'ap-requests-deliver-failed'; -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const props = withDefaults(defineProps<{ + src: HeatmapSource; + user?: Misskey.entities.User; + label?: string; +}>(), { + user: undefined, + label: '', +}); + +const rootEl = shallowRef<HTMLDivElement | null>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ @@ -38,6 +46,7 @@ const { handler: externalTooltipHandler } = useChartTooltip({ }); async function renderChart() { + if (rootEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -56,7 +65,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => { const dt = getDate(i); const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; @@ -69,22 +78,27 @@ async function renderChart() { }); }; - let values; + let values: number[] = []; if (props.src === 'active-users') { - const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); values = raw.readWrite; } else if (props.src === 'notes') { - const raw = await os.api('charts/notes', { limit: chartLimit, span: 'day' }); - values = raw.local.inc; + if (props.user) { + const raw = await misskeyApi('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); + values = raw.inc; + } else { + const raw = await misskeyApi('charts/notes', { limit: chartLimit, span: 'day' }); + values = raw.local.inc; + } } else if (props.src === 'ap-requests-inbox-received') { - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); values = raw.inboxReceived; } else if (props.src === 'ap-requests-deliver-succeeded') { - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); values = raw.deliverSucceeded; } else if (props.src === 'ap-requests-deliver-failed') { - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); values = raw.deliverFailed; } @@ -101,25 +115,25 @@ async function renderChart() { const marginEachCell = 4; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ - label: 'Read & Write', - data: format(values), - pointRadius: 0, + label: props.label, + data: format(values) as any, borderWidth: 0, - borderJoinStyle: 'round', borderRadius: 3, backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; + // @ts-expect-error TS(2339) + const value = c.dataset.data[c.dataIndex].v as number; let a = (value - min) / max; if (value !== 0) { // 0でない限りは完全に不可視にはしない a = Math.max(a, 0.05); } return alpha(color, a); }, - fill: true, width(c) { const a = c.chart.chartArea ?? {}; return (a.right - a.left) / weeks - marginEachCell; @@ -128,6 +142,9 @@ async function renderChart() { const a = c.chart.chartArea ?? {}; return (a.bottom - a.top) / 7 - marginEachCell; }, + /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> + }] satisfies ChartData[], + */ }], }, options: { @@ -190,12 +207,14 @@ async function renderChart() { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; - return v.d; + // @ts-expect-error TS(2339) + return context[0].dataset.data[context[0].dataIndex].d; }, label(context) { const v = context.dataset.data[context.dataIndex]; - return ['Active: ' + v.v]; + + // @ts-expect-error TS(2339) + return [v.v]; }, }, //mode: 'index', diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue new file mode 100644 index 0000000000..196c962a06 --- /dev/null +++ b/packages/frontend/src/components/MkHorizontalSwipe.vue @@ -0,0 +1,239 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + :class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]" + @touchstart.passive="touchStart" + @touchmove.passive="touchMove" + @touchend.passive="touchEnd" +> + <Transition + :class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]" + :enterActiveClass="$style.swipeAnimation_enterActive" + :leaveActiveClass="$style.swipeAnimation_leaveActive" + :enterFromClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_enterFrom : $style.swipeAnimationRight_enterFrom" + :leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo" + :style="`--swipe: ${pullDistance}px;`" + > + <!-- 【注意】slot内の最上位要素に動的にkeyを設定すること --> + <!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません --> + <slot></slot> + </Transition> +</div> +</template> +<script lang="ts" setup> +import { ref, shallowRef, computed, nextTick, watch } from 'vue'; +import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; +import { defaultStore } from '@/store.js'; +import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js'; + +const rootEl = shallowRef<HTMLDivElement>(); + +// eslint-disable-next-line no-undef +const tabModel = defineModel<string>('tab'); + +const props = defineProps<{ + tabs: Tab[]; +}>(); + +const emit = defineEmits<{ + (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void; +}>(); + +const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value); + +// ▼ しきい値 ▼ // + +// スワイプと判定される最小の距離 +const MIN_SWIPE_DISTANCE = 20; + +// スワイプ時の動作を発火する最小の距離 +const SWIPE_DISTANCE_THRESHOLD = 70; + +// スワイプを中断するY方向の移動距離 +const SWIPE_ABORT_Y_THRESHOLD = 75; + +// スワイプできる最大の距離 +const MAX_SWIPE_DISTANCE = 120; + +// ▲ しきい値 ▲ // + +let startScreenX: number | null = null; +let startScreenY: number | null = null; + +const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value)); + +const pullDistance = ref(0); +const isSwipingForClass = ref(false); +let swipeAborted = false; + +function touchStart(event: TouchEvent) { + if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + + if (event.touches.length !== 1) return; + + if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; + + startScreenX = event.touches[0].screenX; + startScreenY = event.touches[0].screenY; +} + +function touchMove(event: TouchEvent) { + if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + + if (event.touches.length !== 1) return; + + if (startScreenX == null || startScreenY == null) return; + + if (swipeAborted) return; + + if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; + + let distanceX = event.touches[0].screenX - startScreenX; + let distanceY = event.touches[0].screenY - startScreenY; + + if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) { + swipeAborted = true; + + pullDistance.value = 0; + isSwiping.value = false; + setTimeout(() => { + isSwipingForClass.value = false; + }, 400); + + return; + } + + if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return; + if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return; + + if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) { + distanceX = Math.min(distanceX, 0); + } + if (currentTabIndex.value === props.tabs.length - 1 || props.tabs[currentTabIndex.value + 1].onClick) { + distanceX = Math.max(distanceX, 0); + } + if (distanceX === 0) return; + + isSwiping.value = true; + isSwipingForClass.value = true; + nextTick(() => { + // グリッチを控えるため、1.5px以上の差がないと更新しない + if (Math.abs(distanceX - pullDistance.value) < 1.5) return; + pullDistance.value = distanceX; + }); +} + +function touchEnd(event: TouchEvent) { + if (swipeAborted) { + swipeAborted = false; + return; + } + + if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + + if (event.touches.length !== 0) return; + + if (startScreenX == null) return; + + if (!isSwiping.value) return; + + if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return; + + const distance = event.changedTouches[0].screenX - startScreenX; + + if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) { + if (distance > 0) { + if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) { + tabModel.value = props.tabs[currentTabIndex.value - 1].key; + emit('swiped', props.tabs[currentTabIndex.value - 1].key, 'right'); + } + } else { + if (props.tabs[currentTabIndex.value + 1] && !props.tabs[currentTabIndex.value + 1].onClick) { + tabModel.value = props.tabs[currentTabIndex.value + 1].key; + emit('swiped', props.tabs[currentTabIndex.value + 1].key, 'left'); + } + } + } + + pullDistance.value = 0; + isSwiping.value = false; + window.setTimeout(() => { + isSwipingForClass.value = false; + }, 400); +} + +/** 横スワイプに関与する可能性のある要素を調べる */ +function hasSomethingToDoWithXSwipe(el: HTMLElement) { + if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true; + if (el.isContentEditable) return true; + if (el.scrollWidth > el.clientWidth) return true; + + const style = window.getComputedStyle(el); + if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true; + if (['scroll', 'auto'].includes(style.overflowX)) return true; + if (style.touchAction === 'pan-x') return true; + + if (el.parentElement && el.parentElement !== rootEl.value) { + return hasSomethingToDoWithXSwipe(el.parentElement); + } else { + return false; + } +} + +const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined); + +watch(tabModel, (newTab, oldTab) => { + const newIndex = props.tabs.findIndex(tab => tab.key === newTab); + const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab); + + if (oldIndex >= 0 && newIndex && oldIndex < newIndex) { + transitionName.value = 'swipeAnimationLeft'; + } else { + transitionName.value = 'swipeAnimationRight'; + } + + window.setTimeout(() => { + transitionName.value = undefined; + }, 400); +}); +</script> + +<style lang="scss" module> +.transitionRoot { + touch-action: pan-y pinch-zoom; + display: grid; + grid-template-columns: 100%; + overflow: clip; +} + +.transitionChildren { + grid-area: 1 / 1 / 2 / 2; + transform: translateX(var(--swipe)); +} + +.enableAnimation .transitionChildren { + &.swipeAnimation_enterActive, + &.swipeAnimation_leaveActive { + transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1); + } + + &.swipeAnimationRight_leaveTo, + &.swipeAnimationLeft_enterFrom { + transform: translateX(calc(100% + 24px)); + } + + &.swipeAnimationRight_enterFrom, + &.swipeAnimationLeft_leaveTo { + transform: translateX(calc(-100% - 24px)); + } +} + +.swiping { + transition: transform .2s ease-out; +} +</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 942861e1f4..4e3fafe845 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -73,7 +73,7 @@ const props = withDefaults(defineProps<{ leaveFromClass?: string; } | null; src?: string | null; - hash?: string; + hash?: string | null; alt?: string | null; title?: string | null; height?: number; diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index 6e643639f2..9a5874b5c0 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index b4b4e1b0b7..b026903b66 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -88,17 +88,18 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLElement>(); +const inputEl = shallowRef<HTMLInputElement>(); const prefixEl = shallowRef<HTMLElement>(); const suffixEl = shallowRef<HTMLElement>(); const height = props.small ? 33 : props.large ? 39 : 36; -let autocomplete: Autocomplete; +let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value.focus(); -const onInput = (ev: KeyboardEvent) => { +const focus = () => inputEl.value?.focus(); +const onInput = (event: Event) => { + const ev = event as KeyboardEvent; changed.value = true; emit('change', ev); }; @@ -115,9 +116,9 @@ const onKeydown = (ev: KeyboardEvent) => { const updated = () => { changed.value = false; if (type.value === 'number') { - emit('update:modelValue', parseFloat(v.value)); + emit('update:modelValue', typeof v.value === 'number' ? v.value : parseFloat(v.value ?? '0')); } else { - emit('update:modelValue', v.value); + emit('update:modelValue', v.value ?? ''); } }; @@ -127,7 +128,7 @@ watch(modelValue, newValue => { v.value = newValue; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); @@ -136,12 +137,14 @@ watch(v, newValue => { } } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { + if (inputEl.value == null) return; + if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; @@ -163,15 +166,15 @@ onMounted(() => { focus(); } }); - - if (props.mfmAutocomplete) { - autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + + if (props.mfmAutocomplete && inputEl.value) { + autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { - if (autocomplete) { - autocomplete.detach(); + if (autocompleteWorker) { + autocompleteWorker.detach(); } }); diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index 9cde197e19..feb62415aa 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ @@ -27,7 +27,7 @@ const props = defineProps<{ const chartValues = ref<number[] | null>(null); -os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { +misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く res['requests.received'].splice(0, 1); chartValues.value = res['requests.received']; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 7b763ad385..d74c885041 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> </MkSelect> <div class="_panel" :class="$style.heatmap"> - <MkHeatmap :src="heatmapSrc"/> + <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> </div> </MkFoldableSection> @@ -90,8 +90,9 @@ import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import MkHeatmap from '@/components/MkHeatmap.vue'; +import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; @@ -102,7 +103,7 @@ initChart(); const chartLimit = 500; const chartSpan = ref<'hour' | 'day'>('hour'); const chartSrc = ref('active-users'); -const heatmapSrc = ref('active-users'); +const heatmapSrc = ref<HeatmapSource>('active-users'); const subDoughnutEl = shallowRef<HTMLCanvasElement>(); const pubDoughnutEl = shallowRef<HTMLCanvasElement>(); @@ -137,7 +138,8 @@ function createDoughnut(chartEl, tooltip, data) { }, }, onClick: (ev) => { - const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (ev.native == null) return; + const hit = chartInstance.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; if (hit && data[hit.index].onClick) { data[hit.index].onClick(); } @@ -162,24 +164,47 @@ function createDoughnut(chartEl, tooltip, data) { } onMounted(() => { - os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { - createDoughnut(subDoughnutEl.value, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + misskeyApiGet('federation/stats', { limit: 30 }).then(fedStats => { + type ChartData = { + name: string, + color: string | null, + value: number, + onClick?: () => void, + }[]; + + const subs: ChartData = fedStats.topSubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followersCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); }, - })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); + })); + + subs.push({ + name: '(other)', + color: '#80808080', + value: fedStats.otherFollowersCount, + }); + + createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); - createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + const pubs: ChartData = fedStats.topPubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followingCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); }, - })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); + })); + + pubs.push({ + name: '(other)', + color: '#80808080', + value: fedStats.otherFollowingCount, + }); + + createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); }); }); </script> diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index e358a1c549..094d2f177f 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,9 +18,9 @@ import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ instance?: { - faviconUrl?: string - name: string - themeColor?: string + faviconUrl?: string | null + name?: string | null + themeColor?: string | null } }>(); @@ -30,7 +30,7 @@ const instance = props.instance ?? { themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, }; -const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); +const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? '/favicon.ico'); const themeColor = instance.themeColor ?? '#777777'; diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts index 2ea32dd3b6..456d215288 100644 --- a/packages/frontend/src/components/MkInviteCode.stories.impl.ts +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed, inviteCode } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkInviteCode from './MkInviteCode.vue'; @@ -39,8 +39,8 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/show', (req, res, ctx) => { - return res(ctx.json(userDetailed(req.params.userId as string))); + http.post('/api/users/show', ({ params }) => { + return HttpResponse.json(userDetailed(params.userId as string)); }), ], }, diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 54d997d1c9..b095df20e5 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 7a1a5eb016..2175c0e888 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 099082f539..e232b4d66f 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> @@ -63,7 +63,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => })); function close() { - modal.value.close(); + modal.value?.close(); } </script> @@ -119,6 +119,7 @@ function close() { margin-top: 12px; font-size: 0.8em; line-height: 1.5em; + text-align: center; } > .indicatorWithValue { @@ -138,7 +139,7 @@ function close() { left: 32px; color: var(--indicator); font-size: 8px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; @media (max-width: 500px) { top: 16px; diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index bda683002d..95de0d0247 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <component :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target" :title="url" + @click.stop > <slot></slot> <i v-if="target === '_blank'" class="ph-arrow-square-out ph-bold ph-lg" :class="$style.icon"></i> diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue index 145b60c8e7..4a89d21b92 100644 --- a/packages/frontend/src/components/MkMarquee.vue +++ b/packages/frontend/src/components/MkMarquee.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,6 +30,7 @@ export default { const contentEl = ref<HTMLElement>(); function calc() { + if (contentEl.value == null) return; const eachLength = contentEl.value.offsetWidth / props.repeat; const factor = 3000; const duration = props.duration / ((1 / eachLength) * factor); diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue new file mode 100644 index 0000000000..6351f5cfbe --- /dev/null +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -0,0 +1,364 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="[ + $style.audioContainer, + (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + ]" + @contextmenu.stop +> + <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="audio.isSensitive" style="display: block;"><i class="ph-eye-slash ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ph-music-notes ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </button> + <div v-else :class="$style.audioControls"> + <audio + ref="audioEl" + preload="metadata" + > + <source :src="audio.url"> + </audio> + <div :class="[$style.controlsChild, $style.controlsLeft]"> + <button class="_button" :class="$style.controlButton" @click="togglePlayPause"> + <i v-if="isPlaying" class="ph-pause ph-bold ph-lg"></i> + <i v-else class="ph-play ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsRight]"> + <a class="_button" :class="$style.controlButton" :href="audio.url" :download="audio.name" target="_blank"> + <i class="ph-download ph-bold ph-lg"></i> + </a> + <button class="_button" :class="$style.controlButton" @click="showMenu"> + <i class="ph-gear ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> + <div :class="[$style.controlsChild, $style.controlsVolume]"> + <button class="_button" :class="$style.controlButton" @click="toggleMute"> + <i v-if="volume === 0" class="ph-speaker-x ph-bold ph-lg"></i> + <i v-else class="ph-speaker-high ph-bold ph-lg"></i> + </button> + <MkMediaRange + v-model="volume" + :class="$style.volumeSeekbar" + /> + </div> + <MkMediaRange + v-model="rangePercent" + :class="$style.seekbarRoot" + :buffer="bufferedDataRatio" + /> + </div> +</div> +</template> + +<script lang="ts" setup> +import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; +import { defaultStore } from '@/store.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import bytes from '@/filters/bytes.js'; +import { hms } from '@/filters/hms.js'; +import MkMediaRange from '@/components/MkMediaRange.vue'; +import { iAmModerator } from '@/account.js'; + +const props = defineProps<{ + audio: Misskey.entities.DriveFile; +}>(); + +const audioEl = shallowRef<HTMLAudioElement>(); + +// eslint-disable-next-line vue/no-setup-props-destructure +const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); + +// Menu +const menuShowing = ref(false); + +function showMenu(ev: MouseEvent) { + let menu: MenuItem[] = []; + + menu = [ + // TODO: 再生キューに追加 + { + text: i18n.ts.hide, + icon: 'ph-eye-closed ph-bold ph-lg', + action: () => { + hide.value = true; + }, + }, + ]; + + if (iAmModerator) { + menu.push({ + type: 'divider', + }, { + text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.audio.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', + danger: true, + action: () => toggleSensitive(props.audio), + }); + } + + menuShowing.value = true; + os.popupMenu(menu, ev.currentTarget ?? ev.target, { + align: 'right', + onClosing: () => { + menuShowing.value = false; + }, + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.apiWithDialog('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +// MediaControl: Common State +const oncePlayed = ref(false); +const isReady = ref(false); +const isPlaying = ref(false); +const isActuallyPlaying = ref(false); +const elapsedTimeMs = ref(0); +const durationMs = ref(0); +const rangePercent = computed({ + get: () => { + return (elapsedTimeMs.value / durationMs.value) || 0; + }, + set: (to) => { + if (!audioEl.value) return; + audioEl.value.currentTime = to * durationMs.value / 1000; + }, +}); +const volume = ref(.25); +const bufferedEnd = ref(0); +const bufferedDataRatio = computed(() => { + if (!audioEl.value) return 0; + return bufferedEnd.value / audioEl.value.duration; +}); + +// MediaControl Events +function togglePlayPause() { + if (!isReady.value || !audioEl.value) return; + + if (isPlaying.value) { + audioEl.value.pause(); + isPlaying.value = false; + } else { + audioEl.value.play(); + isPlaying.value = true; + oncePlayed.value = true; + } +} + +function toggleMute() { + if (volume.value === 0) { + volume.value = .25; + } else { + volume.value = 0; + } +} + +let onceInit = false; +let stopAudioElWatch: () => void; + +function init() { + if (onceInit) return; + onceInit = true; + + stopAudioElWatch = watch(audioEl, () => { + if (audioEl.value) { + isReady.value = true; + + function updateMediaTick() { + if (audioEl.value) { + try { + bufferedEnd.value = audioEl.value.buffered.end(0); + } catch (err) { + bufferedEnd.value = 0; + } + + elapsedTimeMs.value = audioEl.value.currentTime * 1000; + } + window.requestAnimationFrame(updateMediaTick); + } + + updateMediaTick(); + + audioEl.value.addEventListener('play', () => { + isActuallyPlaying.value = true; + }); + + audioEl.value.addEventListener('pause', () => { + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + audioEl.value.addEventListener('ended', () => { + oncePlayed.value = false; + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + durationMs.value = audioEl.value.duration * 1000; + audioEl.value.addEventListener('durationchange', () => { + if (audioEl.value) { + durationMs.value = audioEl.value.duration * 1000; + } + }); + + audioEl.value.volume = volume.value; + } + }, { + immediate: true, + }); +} + +watch(volume, (to) => { + if (audioEl.value) audioEl.value.volume = to; +}); + +onMounted(() => { + init(); +}); + +onActivated(() => { + init(); +}); + +onDeactivated(() => { + isReady.value = false; + isPlaying.value = false; + isActuallyPlaying.value = false; + elapsedTimeMs.value = 0; + durationMs.value = 0; + bufferedEnd.value = 0; + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); + stopAudioElWatch(); + onceInit = false; +}); +</script> + +<style lang="scss" module> +.audioContainer { + container-type: inline-size; + position: relative; + border: .5px solid var(--divider); + border-radius: var(--radius); + overflow: clip; +} + +.sensitive { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + border-radius: inherit; + box-shadow: inset 0 0 0 4px var(--warn); + } +} + +.hidden { + width: 100%; + background: #000; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 12px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.audioControls { + display: grid; + grid-template-areas: + "left time . volume right" + "seekbar seekbar seekbar seekbar seekbar"; + grid-template-columns: auto auto 1fr auto auto; + align-items: center; + gap: 4px 8px; + padding: 10px; +} + +.controlsChild { + display: flex; + align-items: center; + gap: 4px; + + .controlButton { + padding: 6px; + border-radius: calc(var(--radius) / 2); + font-size: 1.05rem; + + &:hover { + color: var(--accent); + background-color: var(--accentedBg); + } + } +} + +.controlsLeft { + grid-area: left; +} + +.controlsRight { + grid-area: right; +} + +.controlsTime { + grid-area: time; + font-size: .9rem; +} + +.controlsVolume { + grid-area: volume; + + .volumeSeekbar { + display: none; + } +} + +.seekbarRoot { + grid-area: seekbar; +} + +@container (min-width: 500px) { + .audioControls { + grid-template-areas: "left seekbar time volume right"; + grid-template-columns: auto 1fr auto auto auto; + } + + .controlsVolume { + .volumeSeekbar { + max-width: 90px; + display: block; + flex-grow: 1; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 7b0387cefe..605c1a4c80 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,15 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> - <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :class="$style.audio"> - <audio - ref="audioEl" - :src="media.url" - :title="media.comment ?? undefined" - controls - preload="metadata" - /> - </div> + <MkMediaAudio v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> <a v-else :class="$style.download" :href="media.url" @@ -35,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { shallowRef, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; +import MkMediaAudio from '@/components/MkMediaAudio.vue'; const props = withDefaults(defineProps<{ media: Misskey.entities.DriveFile; diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index ef57cea32a..3f9cff8b71 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> <div v-if="image.comment" :class="$style.indicator">ALT</div> <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ph-eye-closed ph-bold ph-lg"></i></div> - <div v-if="!image.comment" :class="$style.indicator" title="Image lacks descriptive text"><i class="ph-pencil ph-bold ph-lg-off"></i></div> + <div v-if="!image.comment" :class="$style.indicator" title="Image lacks descriptive text"><i class="ph-pencil-simple ph-bold ph-lg-off"></i></div> </div> <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ph-dots-three ph-bold ph-lg" style="vertical-align: middle;"></i></button> <i class="ph-eye-slash ph-bold ph-lg" :class="$style.hide" @click.stop="hide = true"></i> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 8f73018734..3bf44aea8e 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -54,7 +54,7 @@ const count = computed(() => props.mediaList.filter(media => previewable(media)) let lightbox: PhotoSwipeLightbox | null; const popstateHandler = (): void => { - if (lightbox.pswp && lightbox.pswp.isOpen === true) { + if (lightbox?.pswp && lightbox.pswp.isOpen === true) { lightbox.pswp.close(); } }; @@ -69,7 +69,10 @@ async function calcAspectRatio() { return; } - const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + const ratioMax = (ratio: number) => { + if (img.properties.width == null || img.properties.height == null) return ''; + return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; + }; switch (defaultStore.state.mediaListWithOneImageAppearance) { case '16_9': @@ -145,7 +148,7 @@ onMounted(() => { // element is children const { element } = itemData; - const id = element.dataset.id; + const id = element?.dataset.id; const file = props.mediaList.find(media => media.id === id); if (!file) return; @@ -155,14 +158,14 @@ onMounted(() => { if (file.properties.orientation != null && file.properties.orientation >= 5) { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } - itemData.msrc = file.thumbnailUrl; + itemData.msrc = file.thumbnailUrl ?? undefined; itemData.alt = file.comment ?? undefined; itemData.comment = file.comment; itemData.thumbCropped = true; }); lightbox.on('uiRegister', () => { - lightbox.pswp.ui.registerElement({ + lightbox?.pswp?.ui?.registerElement({ name: 'altText', className: 'pwsp__alt-text-container', appendTo: 'wrapper', @@ -178,7 +181,7 @@ onMounted(() => { textBox.style.display = 'none'; } - textBox.textContent = pwsp.currSlide.data.comment; + textBox.textContent = pwsp.currSlide?.data.comment; }); }, }); diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue new file mode 100644 index 0000000000..86ed8ba2cf --- /dev/null +++ b/packages/frontend/src/components/MkMediaRange.vue @@ -0,0 +1,152 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- Media系専用のinput range --> +<template> +<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'"> + <div :class="$style.controlsSeekbar"> + <progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress> + <input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/> + </div> +</div> +</template> + +<script setup lang="ts"> +import { computed, ModelRef } from 'vue'; + +withDefaults(defineProps<{ + buffer?: number; + sliderBgWhite?: boolean; +}>(), { + buffer: undefined, + sliderBgWhite: false, +}); + +const emit = defineEmits<{ + (ev: 'dragEnded', value: number): void; +}>(); + +// eslint-disable-next-line no-undef +const model = defineModel({ required: true }) as ModelRef<string | number>; +const modelValue = computed({ + get: () => typeof model.value === 'number' ? model.value : parseFloat(model.value), + set: v => { model.value = v; }, +}); +</script> + +<style lang="scss" module> +.controlsSeekbar { + position: relative; +} + +.seek { + position: relative; + -webkit-appearance: none; + appearance: none; + background: transparent; + border: 0; + border-radius: 26px; + color: var(--accent); + display: block; + height: 19px; + margin: 0; + min-width: 0; + padding: 0; + transition: box-shadow .3s ease; + width: 100%; + + &::-webkit-slider-runnable-track { + background-color: var(--sliderBg); + background-image: linear-gradient(to right,currentColor var(--value,0),transparent var(--value,0)); + border: 0; + border-radius: 99rem; + height: 5px; + transition: box-shadow .3s ease; + user-select: none; + } + + &::-moz-range-track { + background: transparent; + border: 0; + border-radius: 99rem; + height: 5px; + transition: box-shadow .3s ease; + user-select: none; + background-color: var(--sliderBg); + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #fff; + border: 0; + border-radius: 100%; + box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2); + height: 13px; + margin-top: -4px; + position: relative; + transition: all .2s ease; + width: 13px; + + &:active { + box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5); + } + } + + &::-moz-range-thumb { + background: #fff; + border: 0; + border-radius: 100%; + box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2); + height: 13px; + position: relative; + transition: all .2s ease; + width: 13px; + + &:active { + box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5); + } + } + + &::-moz-range-progress { + background: currentColor; + border-radius: 99rem; + height: 5px; + } +} + +.buffer { + appearance: none; + background: transparent; + color: var(--sliderBg); + border: 0; + border-radius: 99rem; + height: 5px; + left: 0; + margin-top: -2.5px; + padding: 0; + position: absolute; + top: 50%; + width: 100%; + + &::-webkit-progress-bar { + background: transparent; + } + + &::-webkit-progress-value { + background: currentColor; + border-radius: 100px; + min-width: 5px; + transition: width .2s ease; + } + + &::-moz-progress-bar { + background: currentColor; + border-radius: 100px; + min-width: 5px; + transition: width .2s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index a1950b110a..7c14ade130 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,71 +1,351 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="hide" :class="[$style.hidden, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]" @click="hide = false"> - <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること --> - <div :class="$style.sensitive"> - <b v-if="video.isSensitive" style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ph-film-strip ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> - <span>{{ i18n.ts.clickToShow }}</span> - </div> -</div> -<div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]"> - <video - ref="videoEl" - :class="$style.video" - :poster="video.thumbnailUrl" - :title="video.comment ?? undefined" - :alt="video.comment" - preload="none" - controls - @contextmenu.stop - > - <source - :src="video.url" +<div + ref="playerEl" + :class="[ + $style.videoContainer, + controlsShowing && $style.active, + (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + ]" + @mouseover="onMouseOver" + @mouseleave="onMouseLeave" + @contextmenu.stop +> + <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="video.isSensitive" style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ph-film-strip ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </button> + <div v-else :class="$style.videoRoot" @click.self="togglePlayPause"> + <video + ref="videoEl" + :class="$style.video" + :poster="video.thumbnailUrl ?? undefined" + :title="video.comment ?? undefined" + :alt="video.comment" + preload="metadata" + playsinline > - </video> - <i class="ph-eye-slash ph-bold ph-lg" :class="$style.hide" @click="hide = true"></i> + <source :src="video.url"> + </video> + <button v-if="isReady && !isPlaying" class="_button" :class="$style.videoOverlayPlayButton" @click="togglePlayPause"><i class="ph-play ph-bold ph-lg"></i></button> + <div v-else-if="!isActuallyPlaying" :class="$style.videoLoading"> + <MkLoading/> + </div> + <i class="ph-eye-closed ph-bold ph-lg" :class="$style.hide" @click="hide = true"></i> + <div :class="$style.indicators"> + <div v-if="video.comment" :class="$style.indicator">ALT</div> + <div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ph-warning ph-bold ph-lg"></i></div> + </div> + <div :class="$style.videoControls" @click.self="togglePlayPause"> + <div :class="[$style.controlsChild, $style.controlsLeft]"> + <button class="_button" :class="$style.controlButton" @click="togglePlayPause"> + <i v-if="isPlaying" class="ph-pause ph-bold ph-lg"></i> + <i v-else class="ph-play ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsRight]"> + <a class="_button" :class="$style.controlButton" :href="video.url" :download="video.name" target="_blank"> + <i class="ph-download ph-bold ph-lg"></i> + </a> + <button class="_button" :class="$style.controlButton" @click="showMenu"> + <i class="ph-gear ph-bold ph-lg"></i> + </button> + <button class="_button" :class="$style.controlButton" @click="toggleFullscreen"> + <i v-if="isFullscreen" class="ph-arrows-in ph-bold ph-lg"></i> + <i v-else class="ph-arrows-out ph-bold ph-lg"></i> + </button> + </div> + <div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div> + <div :class="[$style.controlsChild, $style.controlsVolume]"> + <button class="_button" :class="$style.controlButton" @click="toggleMute"> + <i v-if="volume === 0" class="ph-speaker-x ph-bold ph-lg"></i> + <i v-else class="ph-speaker-high ph-bold ph-lg"></i> + </button> + <MkMediaRange + v-model="volume" + :sliderBgWhite="true" + :class="$style.volumeSeekbar" + /> + </div> + <MkMediaRange + v-model="rangePercent" + :sliderBgWhite="true" + :class="$style.seekbarRoot" + :buffer="bufferedDataRatio" + /> + </div> + </div> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; import bytes from '@/filters/bytes.js'; +import { hms } from '@/filters/hms.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { isFullscreenNotSupported } from '@/scripts/device-kind.js'; import hasAudio from '@/scripts/media-has-audio.js'; +import MkMediaRange from '@/components/MkMediaRange.vue'; +import { iAmModerator } from '@/account.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; }>(); +// eslint-disable-next-line vue/no-setup-props-destructure const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +// Menu +const menuShowing = ref(false); + +function showMenu(ev: MouseEvent) { + let menu: MenuItem[] = []; + + menu = [ + // TODO: 再生キューに追加 + { + text: i18n.ts.hide, + icon: 'ph-eye-closed ph-bold ph-lg', + action: () => { + hide.value = true; + }, + }, + ]; + + if (iAmModerator) { + menu.push({ + type: 'divider', + }, { + text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.video.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg', + danger: true, + action: () => toggleSensitive(props.video), + }); + } + + menuShowing.value = true; + os.popupMenu(menu, ev.currentTarget ?? ev.target, { + align: 'right', + onClosing: () => { + menuShowing.value = false; + }, + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.apiWithDialog('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +// MediaControl: Video State const videoEl = shallowRef<HTMLVideoElement>(); +const playerEl = shallowRef<HTMLDivElement>(); +const isHoverring = ref(false); +const controlsShowing = computed(() => { + if (!oncePlayed.value) return true; + if (isHoverring.value) return true; + if (menuShowing.value) return true; + return false; +}); +const isFullscreen = ref(false); +let controlStateTimer: string | number; -watch(videoEl, () => { - if (videoEl.value) { - videoEl.value.volume = 0.3; - hasAudio(videoEl.value).then(had => { - if (!had) { - videoEl.value.loop = videoEl.value.muted = true; - videoEl.value.play(); +// MediaControl: Common State +const oncePlayed = ref(false); +const isReady = ref(false); +const isPlaying = ref(false); +const isActuallyPlaying = ref(false); +const elapsedTimeMs = ref(0); +const durationMs = ref(0); +const rangePercent = computed({ + get: () => { + return (elapsedTimeMs.value / durationMs.value) || 0; + }, + set: (to) => { + if (!videoEl.value) return; + videoEl.value.currentTime = to * durationMs.value / 1000; + }, +}); +const volume = ref(.25); +const bufferedEnd = ref(0); +const bufferedDataRatio = computed(() => { + if (!videoEl.value) return 0; + return bufferedEnd.value / videoEl.value.duration; +}); + +// MediaControl Events +function onMouseOver() { + if (controlStateTimer) { + clearTimeout(controlStateTimer); + } + isHoverring.value = true; +} + +function onMouseLeave() { + controlStateTimer = window.setTimeout(() => { + isHoverring.value = false; + }, 100); +} + +function togglePlayPause() { + if (!isReady.value || !videoEl.value) return; + + if (isPlaying.value) { + videoEl.value.pause(); + isPlaying.value = false; + } else { + videoEl.value.play(); + isPlaying.value = true; + oncePlayed.value = true; + } +} + +function toggleFullscreen() { + if (isFullscreenNotSupported && videoEl.value) { + if (isFullscreen.value) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + videoEl.value.webkitExitFullscreen(); + isFullscreen.value = false; + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + videoEl.value.webkitEnterFullscreen(); + isFullscreen.value = true; + } + } else if (playerEl.value) { + if (isFullscreen.value) { + document.exitFullscreen(); + isFullscreen.value = false; + } else { + playerEl.value.requestFullscreen({ navigationUI: 'hide' }); + isFullscreen.value = true; + } + } +} + +function toggleMute() { + if (volume.value === 0) { + volume.value = .25; + } else { + volume.value = 0; + } +} + +let onceInit = false; +let stopVideoElWatch: () => void; + +function init() { + if (onceInit) return; + onceInit = true; + + stopVideoElWatch = watch(videoEl, () => { + if (videoEl.value) { + isReady.value = true; + + function updateMediaTick() { + if (videoEl.value) { + try { + bufferedEnd.value = videoEl.value.buffered.end(0); + } catch (err) { + bufferedEnd.value = 0; + } + + elapsedTimeMs.value = videoEl.value.currentTime * 1000; + } + window.requestAnimationFrame(updateMediaTick); } - }); + + updateMediaTick(); + + videoEl.value.addEventListener('play', () => { + isActuallyPlaying.value = true; + }); + + videoEl.value.addEventListener('pause', () => { + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + videoEl.value.addEventListener('ended', () => { + oncePlayed.value = false; + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + durationMs.value = videoEl.value.duration * 1000; + videoEl.value.addEventListener('durationchange', () => { + if (videoEl.value) { + durationMs.value = videoEl.value.duration * 1000; + } + }); + + videoEl.value.volume = volume.value; + hasAudio(videoEl.value).then(had => { + if (!had && videoEl.value) { + videoEl.value.loop = videoEl.value.muted = true; + videoEl.value.play(); + } + }); + } + }, { + immediate: true, + }); +} + +watch(volume, (to) => { + if (videoEl.value) videoEl.value.volume = to; +}); + +watch(hide, (to) => { + if (to && isFullscreen.value) { + document.exitFullscreen(); + isFullscreen.value = false; } }); + +onMounted(() => { + init(); +}); + +onActivated(() => { + init(); +}); + +onDeactivated(() => { + isReady.value = false; + isPlaying.value = false; + isActuallyPlaying.value = false; + elapsedTimeMs.value = 0; + durationMs.value = 0; + bufferedEnd.value = 0; + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'); + stopVideoElWatch(); + onceInit = false; +}); </script> <style lang="scss" module> -.visible { +.videoContainer { + container-type: inline-size; position: relative; + overflow: clip; } -.sensitiveContainer { +.sensitive { position: relative; &::after { @@ -81,45 +361,199 @@ watch(videoEl, () => { } } +.indicators { + display: inline-flex; + position: absolute; + top: 10px; + left: 10px; + pointer-events: none; + opacity: .5; + gap: 6px; +} + +.indicator { + /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + background-color: black; + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-weight: bold; + font-size: 0.8em; + padding: 2px 5px; +} + .hide { display: block; position: absolute; border-radius: var(--radius-sm); background-color: black; color: var(--accentLighten); - font-size: 14px; + font-size: 12px; opacity: .5; - padding: 3px 6px; + padding: 5px 8px; text-align: center; cursor: pointer; top: 12px; right: 12px; } -.video { +.hidden { + width: 100%; + height: 100%; + background: #000; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 120px 0; display: flex; - justify-content: center; align-items: center; - font-size: 3.5em; - overflow: hidden; - background-position: center; - background-size: cover; + justify-content: center; +} + +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.videoRoot { + background: #000; + position: relative; width: 100%; height: 100%; + object-fit: contain; } -.hidden { +.video { + display: block; + height: 100%; + width: 100%; +} + +.videoOverlayPlayButton { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + + opacity: 0; + transition: opacity .4s ease-in-out; + + background: var(--accent); + color: #fff; + padding: 1rem; + border-radius: 99rem; + + font-size: 1.1rem; +} + +.videoLoading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; display: flex; + align-items: center; justify-content: center; +} + +.videoControls { + display: grid; + grid-template-areas: + "left time . volume right" + "seekbar seekbar seekbar seekbar seekbar"; + grid-template-columns: auto auto 1fr auto auto; + align-items: center; + gap: 4px 8px; + + padding: 35px 10px 10px 10px; + background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75)); + + position: absolute; + left: 0; + right: 0; + bottom: 0; + + transform: translateY(100%); + pointer-events: none; + opacity: 0; + transition: opacity .4s ease-in-out, transform .4s ease-in-out; +} + +.active { + .videoControls { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + } + + .videoOverlayPlayButton { + opacity: 1; + } +} + +.controlsChild { + display: flex; align-items: center; - background: #111; + gap: 4px; color: #fff; + + .controlButton { + padding: 6px; + border-radius: calc(var(--radius) / 2); + transition: background-color .2s ease-in-out; + font-size: 1.05rem; + + &:hover { + background-color: var(--accent); + } + } } -.sensitive { - display: table-cell; - text-align: center; - font-size: 12px; +.controlsLeft { + grid-area: left; +} + +.controlsRight { + grid-area: right; +} + +.controlsTime { + grid-area: time; + font-size: .9rem; +} + +.controlsVolume { + grid-area: volume; + + .volumeSeekbar { + display: none; + } +} + +.seekbarRoot { + grid-area: seekbar; + /* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */ + margin: -10px; + padding: 10px; +} + +@container (min-width: 500px) { + .videoControls { + grid-template-areas: "left seekbar time volume right"; + grid-template-columns: auto 1fr auto auto auto; + } + + .controlsVolume { + .volumeSeekbar { + max-width: 90px; + display: block; + flex-grow: 1; + } + } } .indicators { display: inline-flex; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index 4d42053657..942c23a145 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,6 +51,7 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages padding: 4px 8px 4px 4px; border-radius: var(--radius-ellipse); color: var(--mention); + white-space: nowrap; &.isMe { color: var(--mentionMe); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index 962dcd91eb..dfb6d34618 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -33,6 +33,7 @@ const align = 'left'; const SCROLLBAR_THICKNESS = 16; function setPosition() { + if (el.value == null) return; const rootRect = props.rootElement.getBoundingClientRect(); const parentRect = props.targetElement.getBoundingClientRect(); const myRect = el.value.getBoundingClientRect(); @@ -66,7 +67,7 @@ const ro = new ResizeObserver((entries, observer) => { }); onMounted(() => { - ro.observe(el.value); + if (el.value) ro.observe(el.value); setPosition(); nextTick(() => { setPosition(); @@ -79,7 +80,7 @@ onUnmounted(() => { defineExpose({ checkHit: (ev: MouseEvent) => { - return (ev.target === el.value || el.value.contains(ev.target)); + return (ev.target === el.value || el.value?.contains(ev.target as Node)); }, }); </script> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 5f48f43bfb..8395879d02 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" @contextmenu.self="e => e.preventDefault()" > - <template v-for="(item, i) in items2"> + <template v-for="(item, i) in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div> <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.caret" style="pointer-events: none;"><i class="ph-caret-right ph-bold ph-lg ti-fw"></i></span> </div> </button> - <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> @@ -63,18 +63,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </button> </template> - <span v-if="items2.length === 0" :class="[$style.none, $style.item]"> + <span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> </div> <div v-if="childMenu"> - <XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned" @close="close(false)"/> + <XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/> </div> </div> </template> <script lang="ts"> -import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; +import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu.js'; @@ -104,7 +104,7 @@ const emit = defineEmits<{ const itemsEl = shallowRef<HTMLDivElement>(); -const items2 = ref<InnerMenuItem[]>([]); +const items2 = ref<InnerMenuItem[]>(); const child = shallowRef<InstanceType<typeof XChild>>(); @@ -119,15 +119,15 @@ const childShowingItem = ref<MenuItem | null>(); let preferClick = isTouchUsing || props.asDrawer; watch(() => props.items, () => { - const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); + const items = [...props.items].filter(item => item !== undefined) as (NonNullable<MenuItem> | MenuPending)[]; for (let i = 0; i < items.length; i++) { const item = items[i]; - if (item && 'then' in item) { // if item is Promise + if ('then' in item) { // if item is Promise items[i] = { type: 'pending' }; item.then(actualItem => { - items2.value[i] = actualItem; + if (items2.value?.[i]) items2.value[i] = actualItem; }); } } @@ -151,7 +151,7 @@ function childActioned() { } const onGlobalMousedown = (event: MouseEvent) => { - if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target))) return; + if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return; if (child.value && child.value.checkHit(event)) return; closeChild(); }; @@ -169,7 +169,7 @@ function onItemMouseLeave(item) { } async function showChildren(item: MenuParent, ev: MouseEvent) { - const children = await (async () => { + const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { return childrenCache.get(item)!; } else { @@ -189,7 +189,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) { }); emit('hide'); } else { - childTarget.value = ev.currentTarget ?? ev.target; + childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement; // これでもリアクティビティは保たれる childMenu.value = children; childShowingItem.value = item; @@ -218,6 +218,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { item.ref = !item.ref; } +function getValue<T>(item?: ComputedRef<T> | T) { + return isRef(item) ? item.value : item; +} + onMounted(() => { if (props.viaKeyboard) { nextTick(() => { @@ -450,7 +454,7 @@ onBeforeUnmount(() => { align-items: center; color: var(--indicator); font-size: 12px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; } .divider { diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index f0a2c232bd..f2f2bf47a8 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only stroke-width="2" /> <circle - :cx="headX" - :cy="headY" + :cx="headX ?? undefined" + :cy="headY ?? undefined" r="3" :fill="color" /> diff --git a/packages/frontend/src/components/MkModPlayer.vue b/packages/frontend/src/components/MkModPlayer.vue index f61144cbca..75053cbc37 100644 --- a/packages/frontend/src/components/MkModPlayer.vue +++ b/packages/frontend/src/components/MkModPlayer.vue @@ -7,14 +7,17 @@ </div> <div v-else class="mod-player-enabled"> - <div class="pattern-display" @click="togglePattern()"> + <div class="pattern-display" @click="togglePattern()" @scroll="scrollHandler" @scrollend="scrollEndHandle"> <div v-if="patternHide" class="pattern-hide"> <b><i class="ph-eye ph-bold ph-lg"></i> Pattern Hidden</b> <span>{{ i18n.ts.clickToShow }}</span> </div> + <span class="patternShadowTop"></span> + <span class="patternShadowBottom"></span> <canvas ref="displayCanvas" class="pattern-canvas"></canvas> </div> <div class="controls"> + <input v-if="patternScrollSliderShow" ref="patternScrollSlider" v-model="patternScrollSliderPos" class="pattern-slider" type="range" min="0" max="100" step="0.01" style=""/> <button class="play" @click="playPause()"> <i v-if="playing" class="ph-pause ph-bold ph-lg"></i> <i v-else class="ph-play ph-bold ph-lg"></i> @@ -33,44 +36,31 @@ </template> <script lang="ts" setup> -import { ref, nextTick, computed } from 'vue'; +import { ref, nextTick, computed, watch, onDeactivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { ChiptuneJsPlayer, ChiptuneJsConfig } from '@/scripts/chiptune2.js'; - -const CHAR_WIDTH = 6; -const CHAR_HEIGHT = 12; -const ROW_OFFSET_Y = 10; +import { isTouchUsing } from '@/scripts/touch.js'; const colours = { background: '#000000', - default: { - active: '#ffffff', - inactive: '#808080', - }, - quarter: { - active: '#ffff00', - inactive: '#ffe135', - }, - instr: { - active: '#80e0ff', - inactive: '#0099cc', - }, - volume: { - active: '#80ff80', - inactive: '#008000', - }, - fx: { - active: '#ff80e0', - inactive: '#800060', - }, - operant: { - active: '#ffe080', - inactive: '#806000', + foreground: { + default: '#ffffff', + quarter: '#ffff00', + instr: '#80e0ff', + volume: '#80ff80', + fx: '#ff80e0', + operant: '#ffe080', }, }; +const CHAR_WIDTH = 6; +const CHAR_HEIGHT = 12; +const ROW_OFFSET_Y = 10; +const MAX_TIME_SPENT = 50; +const MAX_TIME_PER_ROW = 15; + const props = defineProps<{ module: Misskey.entities.DriveFile }>(); @@ -79,29 +69,57 @@ const isSensitive = computed(() => { return props.module.isSensitive; }); const url = computed(() => { return props.module.url; }); let hide = ref((defaultStore.state.nsfw === 'force') ? true : isSensitive.value && (defaultStore.state.nsfw !== 'ignore')); let patternHide = ref(false); -let firstFrame = ref(true); let playing = ref(false); let displayCanvas = ref<HTMLCanvasElement>(); let progress = ref<HTMLProgressElement>(); let position = ref(0); +let patternScrollSlider = ref<HTMLProgressElement>(); +let patternScrollSliderShow = ref(false); +let patternScrollSliderPos = ref(0); const player = ref(new ChiptuneJsPlayer(new ChiptuneJsConfig())); -const rowBuffer = 24; +const maxRowNumbers = 0xFF; +const rowBuffer = 26; let buffer = null; let isSeeking = false; +let firstFrame = true; +let lastPattern = -1; +let lastDrawnRow = -1; +let numberRowCanvas = new OffscreenCanvas(2 * CHAR_WIDTH + 1, maxRowNumbers * CHAR_HEIGHT + 1); +let alreadyHiddenOnce = false; +let alreadyDrawn = [false]; +let patternTime = { 'current': 0, 'max': 0, 'initial': 0 }; -player.value.load(url.value).then((result) => { - buffer = result; - try { - player.value.play(buffer); - progress.value!.max = player.value.duration(); - display(); - } catch (err) { - console.warn(err); +function bakeNumberRow() { + let ctx = numberRowCanvas.getContext('2d', { alpha: false }) as OffscreenCanvasRenderingContext2D; + ctx.font = '10px monospace'; + + for (let i = 0; i < maxRowNumbers; i++) { + let rowText = i.toString(16); + if (rowText.length === 1) rowText = '0' + rowText; + + ctx.fillStyle = colours.foreground.default; + if (i % 4 === 0) ctx.fillStyle = colours.foreground.quarter; + + ctx.fillText(rowText, 0, 10 + i * 12); } - player.value.stop(); -}).catch((error) => { - console.error(error); +} + +onMounted(() => { + player.value.load(url.value).then((result) => { + buffer = result; + try { + player.value.play(buffer); + progress.value!.max = player.value.duration(); + bakeNumberRow(); + display(); + } catch (err) { + console.warn(err); + } + player.value.stop(); + }).catch((error) => { + console.error(error); + }); }); function playPause() { @@ -133,7 +151,7 @@ function stop(noDisplayUpdate = false) { if (!noDisplayUpdate) { try { player.value.play(buffer); - display(); + display(true); } catch (err) { console.warn(err); } @@ -162,104 +180,256 @@ function performSeek() { function toggleVisible() { hide.value = !hide.value; - if (!hide.value && patternHide.value) { - firstFrame.value = true; - patternHide.value = false; + if (!hide.value) { + lastPattern = -1; + lastDrawnRow = -1; } nextTick(() => { stop(hide.value); }); } function togglePattern() { patternHide.value = !patternHide.value; - if (!patternHide.value) { - if (player.value.getRow() === 0) { - try { - player.value.play(buffer); - display(); - } catch (err) { - console.warn(err); - } - player.value.stop(); + handleScrollBarEnable(); + + if (player.value.getRow() === 0 && player.value.getPattern() === 0) { + try { + player.value.play(buffer); + display(true); + } catch (err) { + console.warn(err); } + player.value.stop(); + } else { + display(true); } } -function display() { - if (!displayCanvas.value) { - stop(); - return; +function drawPattern() { + if (!displayCanvas.value) return; + const canvas = displayCanvas.value; + + const startTime = performance.now(); + const pattern = player.value.getPattern(); + const nbRows = player.value.getPatternNumRows(pattern); + const row = player.value.getRow(); + const halfbuf = rowBuffer / 2; + const minRow = row - halfbuf; + const maxRow = row + halfbuf; + + let rowDif = 0; + + let nbChannels = 0; + if (player.value.currentPlayingNode) { + nbChannels = player.value.currentPlayingNode.nbChannels; } + if (pattern === lastPattern) { + rowDif = row - lastDrawnRow; + } else { + if (patternTime.initial !== 0 && !alreadyHiddenOnce) { + const trackerTime = player.value.currentPlayingNode.getProcessTime(); - if (patternHide.value) return; + if (patternTime.initial + trackerTime.max > MAX_TIME_SPENT && trackerTime.max + patternTime.max > MAX_TIME_PER_ROW) { + alreadyHiddenOnce = true; + togglePattern(); + return; + } + } - if (firstFrame.value) { - firstFrame.value = false; - patternHide.value = true; + patternTime = { 'current': 0, 'max': 0, 'initial': 0 }; + alreadyDrawn = []; + if (canvas.width !== (12 + 84 * nbChannels + 2)) canvas.width = 12 + 84 * nbChannels + 2; + if (canvas.height !== (12 * nbRows)) canvas.height = 12 * nbRows; + } + + const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true }) as CanvasRenderingContext2D; + if (ctx.font !== '10px monospace') ctx.font = '10px monospace'; + ctx.imageSmoothingEnabled = false; + if (pattern !== lastPattern) { + ctx.fillStyle = colours.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage( numberRowCanvas, 0, 0 ); } + ctx.fillStyle = colours.foreground.default; + for (let rowOffset = minRow + rowDif; rowOffset < maxRow + rowDif; rowOffset++) { + const rowToDraw = rowOffset - rowDif; + + if (alreadyDrawn[rowToDraw] === true) continue; + + if (rowToDraw >= 0 && rowToDraw < nbRows) { + const baseOffset = 2 * CHAR_WIDTH; + const baseRowOffset = ROW_OFFSET_Y + rowToDraw * CHAR_HEIGHT; + let done = drawRow(ctx, rowToDraw, nbChannels, pattern, baseOffset, baseRowOffset); + + alreadyDrawn[rowToDraw] = done; + } + } + + lastDrawnRow = row; + lastPattern = pattern; + + patternTime.current = performance.now() - startTime; + if (patternTime.initial !== 0 && patternTime.current > patternTime.max) patternTime.max = patternTime.current; + else if (patternTime.initial === 0) patternTime.initial = patternTime.current; +} + +function drawPetternPreview() { + if (!displayCanvas.value) return; const canvas = displayCanvas.value; const pattern = player.value.getPattern(); + const nbRows = player.value.getPatternNumRows(pattern); const row = player.value.getRow(); + const halfbuf = rowBuffer / 2; + alreadyDrawn = []; + let nbChannels = 0; if (player.value.currentPlayingNode) { nbChannels = player.value.currentPlayingNode.nbChannels; } - if (canvas.width !== 12 + 84 * nbChannels + 2) { - canvas.width = 12 + 84 * nbChannels + 2; - canvas.height = 12 * rowBuffer; - } - const nbRows = player.value.getPatternNumRows(pattern); - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + if (canvas.width !== (12 + 84 * nbChannels + 2)) canvas.width = 12 + 84 * nbChannels + 2; + if (canvas.height !== (12 * rowBuffer)) canvas.height = 12 * rowBuffer; + + const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; ctx.font = '10px monospace'; + ctx.imageSmoothingEnabled = false; ctx.fillStyle = colours.background; ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = colours.default.inactive; + ctx.drawImage( numberRowCanvas, 0, (halfbuf - row) * CHAR_HEIGHT ); + for (let rowOffset = 0; rowOffset < rowBuffer; rowOffset++) { - const rowToDraw = row - rowBuffer / 2 + rowOffset; + const rowToDraw = rowOffset + row - halfbuf; + if (rowToDraw >= 0 && rowToDraw < nbRows) { - const active = (rowToDraw === row) ? 'active' : 'inactive'; - let rowText = parseInt(rowToDraw).toString(16); - if (rowText.length === 1) { - rowText = '0' + rowText; - } - ctx.fillStyle = colours.default[active]; - if (rowToDraw % 4 === 0) { - ctx.fillStyle = colours.quarter[active]; - } - ctx.fillText(rowText, 0, 10 + rowOffset * 12); - for (let channel = 0; channel < nbChannels; channel++) { - const part = player.value.getPatternRowChannel(pattern, rowToDraw, channel); - const baseOffset = (2 + (part.length + 1) * channel) * CHAR_WIDTH; - const baseRowOffset = ROW_OFFSET_Y + rowOffset * CHAR_HEIGHT; + const baseOffset = 2 * CHAR_WIDTH; + const baseRowOffset = ROW_OFFSET_Y + rowOffset * CHAR_HEIGHT; + drawRow(ctx, rowToDraw, nbChannels, pattern, baseOffset, baseRowOffset); + } else if (rowToDraw >= 0) { + const baseRowOffset = ROW_OFFSET_Y + rowOffset * CHAR_HEIGHT; + ctx.fillStyle = colours.background; + ctx.fillRect(0, baseRowOffset - CHAR_HEIGHT, CHAR_WIDTH * 2, baseRowOffset); + } + } - ctx.fillStyle = colours.default[active]; - ctx.fillText('|', baseOffset, baseRowOffset); + lastPattern = -1; + lastDrawnRow = -1; +} - const note = part.substring(0, 3); - ctx.fillStyle = colours.default[active]; - ctx.fillText(note, baseOffset + CHAR_WIDTH, baseRowOffset); +function drawRow(ctx: CanvasRenderingContext2D, row: number, channels: number, pattern: number, drawX = (2 * CHAR_WIDTH), drawY = ROW_OFFSET_Y) { + if (!player.value.currentPlayingNode) return false; + if (alreadyDrawn[row]) return true; + const spacer = 11; + const space = ' '; + let seperators = ''; + let note = ''; + let instr = ''; + let volume = ''; + let fx = ''; + let op = ''; + for (let channel = 0; channel < channels; channel++) { + const part = player.value.getPatternRowChannel(pattern, row, channel); - const instr = part.substring(4, 6); - ctx.fillStyle = colours.instr[active]; - ctx.fillText(instr, baseOffset + CHAR_WIDTH * 5, baseRowOffset); + seperators += '|' + space.repeat( spacer + 2 ); + note += part.substring(0, 3) + space.repeat( spacer ); + instr += part.substring(4, 6) + space.repeat( spacer + 1 ); + volume += part.substring(6, 9) + space.repeat( spacer ); + fx += part.substring(10, 11) + space.repeat( spacer + 2 ); + op += part.substring(11, 13) + space.repeat( spacer + 1 ); + } - const volume = part.substring(6, 9); - ctx.fillStyle = colours.volume[active]; - ctx.fillText(volume, baseOffset + CHAR_WIDTH * 7, baseRowOffset); + ctx.fillStyle = colours.foreground.default; + ctx.fillText(seperators, drawX, drawY); - const fx = part.substring(10, 11); - ctx.fillStyle = colours.fx[active]; - ctx.fillText(fx, baseOffset + CHAR_WIDTH * 11, baseRowOffset); + ctx.fillStyle = colours.foreground.default; + ctx.fillText(note, drawX + CHAR_WIDTH, drawY); - const op = part.substring(11, 13); - ctx.fillStyle = colours.operant[active]; - ctx.fillText(op, baseOffset + CHAR_WIDTH * 12, baseRowOffset); - } - } + ctx.fillStyle = colours.foreground.instr; + ctx.fillText(instr, drawX + CHAR_WIDTH * 5, drawY); + + ctx.fillStyle = colours.foreground.volume; + ctx.fillText(volume, drawX + CHAR_WIDTH * 7, drawY); + + ctx.fillStyle = colours.foreground.fx; + ctx.fillText(fx, drawX + CHAR_WIDTH * 11, drawY); + + ctx.fillStyle = colours.foreground.operant; + ctx.fillText(op, drawX + CHAR_WIDTH * 12, drawY); + + return true; +} + +function display(skipOptimizationChecks = false) { + if (!displayCanvas.value || !displayCanvas.value.parentElement) { + stop(); + return; } + + if (patternHide.value && !skipOptimizationChecks) return; + + if (firstFrame) { + // Changing it to false should enable pattern display by default. + patternHide.value = true; + handleScrollBarEnable(); + firstFrame = false; + } + + const row = player.value.getRow(); + const pattern = player.value.getPattern(); + + if ( row === lastDrawnRow && pattern === lastPattern && !skipOptimizationChecks) return; + + // Size vs speed + if (patternHide.value) drawPetternPreview(); + else drawPattern(); + + displayCanvas.value.style.top = !patternHide.value ? 'calc( 50% - ' + (row * CHAR_HEIGHT) + 'px )' : '0%'; } +let suppressScrollSliderWatcher = false; + +function scrollHandler() { + suppressScrollSliderWatcher = true; + + if (!patternScrollSlider.value) return; + if (!displayCanvas.value) return; + if (!displayCanvas.value.parentElement) return; + + patternScrollSliderPos.value = (displayCanvas.value.parentElement.scrollLeft) / (displayCanvas.value.width - displayCanvas.value.parentElement.offsetWidth) * 100; + patternScrollSlider.value.style.opacity = '1'; +} + +function scrollEndHandle() { + suppressScrollSliderWatcher = false; + + if (!patternScrollSlider.value) return; + patternScrollSlider.value.style.opacity = ''; +} + +function handleScrollBarEnable() { + patternScrollSliderShow.value = (!patternHide.value && !isTouchUsing); + if (patternScrollSliderShow.value !== true) return; + + if (!displayCanvas.value) return; + if (!displayCanvas.value.parentElement) return; + if (firstFrame) { + patternScrollSliderShow.value = (12 + 84 * player.value.getPatternNumRows(player.value.getPattern()) + 2 > displayCanvas.value.parentElement.offsetWidth); + } else { + patternScrollSliderShow.value = (displayCanvas.value.width > displayCanvas.value.parentElement.offsetWidth); + } +} + +watch(patternScrollSliderPos, () => { + if (suppressScrollSliderWatcher) return; + if (!displayCanvas.value) return; + if (!displayCanvas.value.parentElement) return; + + displayCanvas.value.parentElement.scrollLeft = (displayCanvas.value.width - displayCanvas.value.parentElement.offsetWidth) * patternScrollSliderPos.value / 100; +}); + +onDeactivated(() => { + stop(); +}); + </script> <style lang="scss" scoped> @@ -290,6 +460,7 @@ function display() { cursor: pointer; top: 12px; right: 12px; + z-index: 4; } > .pattern-display { @@ -299,22 +470,55 @@ function display() { overflow-y: hidden; background-color: black; text-align: center; + max-height: 312px; /* magic_number = CHAR_HEIGHT * rowBuffer, needs to be in px */ + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + .pattern-canvas { + position: relative; background-color: black; - height: 100%; + image-rendering: pixelated; + pointer-events: none; + z-index: 0; } + + .patternShadowTop { + background: #00000080; + width: 100%; + height: calc( 50% - 14px ); + translate: 0 -100%; + top: calc( 50% - 14px ); + position: absolute; + pointer-events: none; + z-index: 2; + } + + .patternShadowBottom { + background: #00000080; + width: 100%; + height: calc( 50% - 12px ); + top: calc( 50% - 1px ); + position: absolute; + pointer-events: none; + z-index: 2; + } + .pattern-hide { display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(64, 64, 64, 0.3); - backdrop-filter: blur(2em); + backdrop-filter: var(--modalBgFilter); color: #fff; font-size: 12px; position: absolute; - z-index: 0; + z-index: 4; width: 100%; height: 100%; @@ -328,7 +532,7 @@ function display() { display: flex; width: 100%; background-color: var(--bg); - z-index: 1; + z-index: 5; > * { padding: 4px 8px; @@ -353,6 +557,18 @@ function display() { margin: 4px 8px; overflow-x: hidden; + &.pattern-slider { + position: absolute; + width: calc( 100% - 8px * 2 ); + top: calc( 100% - 21px * 3 ); + opacity: 0%; + transition: opacity 0.2s; + + &:hover { + opacity: 100%; + } + } + &:focus { outline: none; diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 5cd31cdf7c..40e67fb4e0 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index b91988304d..fc634176c7 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,7 +51,7 @@ const bodyWidth = ref(0); const bodyHeight = ref(0); const close = () => { - modal.value.close(); + modal.value?.close(); }; const onBgClick = () => { @@ -67,11 +67,13 @@ const onKeydown = (evt) => { }; const ro = new ResizeObserver((entries, observer) => { + if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; }); onMounted(() => { + if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; ro.observe(rootEl.value); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 8a3b4cef48..9a667c3118 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -1,13 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div - v-if="!hardMuted && !muted" + v-if="!hardMuted && muted === false" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" :tabindex="!isDeleted ? '-1' : undefined" @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> </div> </div> <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> - <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <MkNoteHeader :note="appearNote" :mini="true" @click.stop/> <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> @@ -74,18 +74,18 @@ SPDX-License-Identifier: AGPL-3.0-only /> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" @click.stop/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> @@ -145,7 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -153,7 +153,14 @@ SPDX-License-Identifier: AGPL-3.0-only </article> </div> <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> - <I18n :src="i18n.ts.userSaysSomething" tag="small"> + <I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> + <I18n v-else :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> @@ -171,7 +178,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; @@ -190,6 +197,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; import * as sound from '@/scripts/sound.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -207,7 +215,8 @@ import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -227,6 +236,7 @@ const emit = defineEmits<{ const router = useRouter(); +const inTimeline = inject<boolean>('inTimeline', false); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); @@ -245,7 +255,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -254,7 +264,7 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } @@ -262,11 +272,11 @@ const isRenote = ( note.value.renote != null && note.value.text == null && note.value.cw == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -276,50 +286,61 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text).filter(u => u !== renoteUrl && u !== renoteUri) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = defaultStore.state.expandLongNote && appearNote.value.cw == null ? false : ref(appearNote.value.cw == null && isLong); +const collapsed = ref(defaultStore.state.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); -const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); +const renoteCollapsed = ref( + defaultStore.state.collapseRenotes && isRenote && ( + ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + (appearNote.value.myReaction != null) + ) +); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean { +/* Overload FunctionにLintが対応していないのでコメントアウト +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; +*/ +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' { if (mutedWords == null) return false; - if (checkWordMute(note, $i, mutedWords)) return true; - if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true; - if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true; + if (checkWordMute(noteToCheck, $i, mutedWords)) return true; + if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true; + if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true; + + if (checkOnly) return false; + + if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute'; return false; } const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -331,7 +352,7 @@ if (props.mock) { }, { deep: true }); } else { useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -340,7 +361,7 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -358,7 +379,7 @@ if (!props.mock) { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -377,7 +398,7 @@ if (!props.mock) { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -387,54 +408,15 @@ if (!props.mock) { } } -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -// defaultStore.state.visibilityがstringなためstringも受け付けている -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -448,7 +430,7 @@ function renote(visibility: Visibility | 'local') { } if (!props.mock) { - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { @@ -456,7 +438,7 @@ function renote(visibility: Visibility | 'local') { renoted.value = true; }); } - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -465,18 +447,10 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - if (!props.mock) { - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -498,9 +472,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -520,9 +494,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -550,7 +524,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -558,10 +532,11 @@ function reply(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -578,17 +553,17 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); - const el = reactButton.value as HTMLElement | null | undefined; + const el = reactButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -597,15 +572,15 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); if (props.mock) { emit('reaction', reaction); return; } - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -618,8 +593,8 @@ function react(viaKeyboard = false): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -627,8 +602,8 @@ function undoReact(note): void { return; } - os.api('notes/reactions/delete', { - noteId: note.id, + misskeyApi('notes/reactions/delete', { + noteId: targetNote.id, }); } @@ -636,7 +611,7 @@ function undoRenote(note) : void { if (props.mock) { return; } - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: note.id, }); os.toast(i18n.ts.rmboost); @@ -656,32 +631,34 @@ function onContextmenu(ev: MouseEvent): void { return; } - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { +function showMenu(viaKeyboard = false): void { if (props.mock) { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -713,7 +690,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -735,7 +712,7 @@ function showRenoteMenu(viaKeyboard = false): void { getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), - $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, + ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, }); @@ -755,23 +732,23 @@ function animatedMFM() { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } function focusBefore() { - focusPrev(el.value); + focusPrev(rootEl.value ?? null); } function focusAfter() { - focusNext(el.value); + focusNext(rootEl.value ?? null); } function readPromo() { - os.api('promo/read', { + misskeyApi('promo/read', { noteId: appearNote.value.id, }); isDeleted.value = true; @@ -825,12 +802,13 @@ function emitUpdReaction(emoji: string, delta: number) { } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } &:hover > .article > .main > .footer > .footerButton { @@ -986,8 +964,8 @@ function emitUpdReaction(emoji: string, delta: number) { flex-shrink: 0; display: block !important; margin: 0 14px 0 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); position: sticky !important; top: calc(22px + var(--stickyTop, 0px)); left: 0; @@ -1249,5 +1227,6 @@ function emitUpdReaction(emoji: string, delta: number) { .clickToOpen { cursor: pointer; + -webkit-tap-highlight-color: transparent; } </style> diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index e287890e2c..3d15f69f73 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!muted" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="$style.root" > @@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> </div> </div> @@ -88,17 +88,17 @@ SPDX-License-Identifier: AGPL-3.0-only <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> @@ -154,7 +154,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -166,11 +166,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button> </div> <div> - <div v-if="tab === 'replies'" :class="$style.tab_replies"> + <div v-if="tab === 'replies'"> <div v-if="!repliesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> </div> - <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" /> + <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/> </div> <div v-else-if="tab === 'renotes'" :class="$style.tab_renotes"> <MkPagination :pagination="renotesPagination" :disableAutoLoad="true"> @@ -183,7 +183,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkPagination> </div> - <div v-if="tab === 'quotes'" :class="$style.tab_replies"> + <div v-if="tab === 'quotes'"> <div v-if="!quotesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> </div> @@ -221,7 +221,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -237,6 +237,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -253,9 +254,10 @@ import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -272,7 +274,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -281,18 +283,18 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } const isRenote = ( note.value.renote != null && note.value.text == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -302,8 +304,6 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); @@ -312,14 +312,14 @@ const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : fals const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); watch(() => props.expandAllCws, (expandAllCws) => { @@ -327,7 +327,7 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -339,14 +339,14 @@ if ($i) { const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -355,7 +355,7 @@ provide('react', (reaction: string) => { const tab = ref('replies'); const reactionTabType = ref<string | null>(null); -const renotesPagination = computed(() => ({ +const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, params: { @@ -363,7 +363,7 @@ const renotesPagination = computed(() => ({ }, })); -const reactionsPagination = computed(() => ({ +const reactionsPagination = computed<Paging>(() => ({ endpoint: 'notes/reactions', limit: 10, params: { @@ -373,20 +373,20 @@ const reactionsPagination = computed(() => ({ })); async function addReplyTo(replyNote: Misskey.entities.Note) { - replies.value.unshift(replyNote); - appearNote.value.repliesCount += 1; + replies.value.unshift(replyNote); + appearNote.value.repliesCount += 1; } async function removeReply(id: Misskey.entities.Note['id']) { - const replyIdx = replies.value.findIndex(note => note.id === id); - if (replyIdx >= 0) { - replies.value.splice(replyIdx, 1); - appearNote.value.repliesCount -= 1; - } + const replyIdx = replies.value.findIndex(note => note.id === id); + if (replyIdx >= 0) { + replies.value.splice(replyIdx, 1); + appearNote.value.repliesCount -= 1; + } } useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -394,7 +394,7 @@ useNoteCapture({ }); useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -412,7 +412,7 @@ useTooltip(renoteButton, async (showing) => { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -430,53 +430,15 @@ useTooltip(quoteButton, async (showing) => { }, {}, 'closed'); }); -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -489,14 +451,14 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -505,17 +467,9 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -533,9 +487,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -555,9 +509,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -583,7 +537,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -592,7 +546,9 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -605,10 +561,10 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -624,7 +580,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -640,14 +597,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -663,26 +620,28 @@ function undoRenote() : void { } function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); +function showMenu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -707,7 +666,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -718,18 +677,18 @@ function showRenoteMenu(viaKeyboard = false): void { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; - os.api('notes/children', { + misskeyApi('notes/children', { noteId: appearNote.value.id, limit: 30, showQuotes: false, @@ -744,7 +703,7 @@ const quotesLoaded = ref(false); function loadQuotes() { quotesLoaded.value = true; - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 30, quote: true, @@ -759,7 +718,8 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; - os.api('notes/conversation', { + if (appearNote.value.replyId == null) return; + misskeyApi('notes/conversation', { noteId: appearNote.value.replyId, }).then(res => { conversation.value = res.reverse(); @@ -871,8 +831,8 @@ function animatedMFM() { .noteHeaderAvatar { display: block; flex-shrink: 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .noteHeaderBody { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 6121db3f8f..e643590e86 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div :class="$style.username"><MkAcct :user="note.user"/></div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> </div> <div :class="$style.info"> <div v-if="mock"> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index c517bc6800..3fcd7593ba 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div> <p v-if="useCw" :class="$style.cw"> - <Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> + <Mfm v-if="cw != null && cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> <MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/> </p> <div v-show="!useCw || showContent"> @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkCwButton from '@/components/MkCwButton.vue'; const showContent = ref(false); @@ -33,12 +34,7 @@ const showContent = ref(false); const props = defineProps<{ text: string; files: Misskey.entities.DriveFile[]; - poll?: { - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; - }; + poll?: PollEditorModelValue; useCw: boolean; cw: string | null; user: Misskey.entities.User; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 7a6109ee0b..477cf4521a 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> + <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> </div> </div> </div> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index d96785a2d9..37811dd52e 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> + <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> <footer :class="$style.footer"> @@ -91,6 +91,8 @@ import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { userPage } from '@/filters/user.js'; @@ -103,6 +105,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); @@ -138,21 +141,21 @@ const replies = ref<Misskey.entities.Note[]>([]); const isRenote = ( props.note.renote != null && props.note.text == null && - props.note.fileIds.length === 0 && + props.note.fileIds && props.note.fileIds.length === 0 && props.note.poll == null ); async function addReplyTo(replyNote: Misskey.entities.Note) { - replies.value.unshift(replyNote); - appearNote.value.repliesCount += 1; + replies.value.unshift(replyNote); + appearNote.value.repliesCount += 1; } async function removeReply(id: Misskey.entities.Note['id']) { - const replyIdx = replies.value.findIndex(note => note.id === id); - if (replyIdx >= 0) { - replies.value.splice(replyIdx, 1); - appearNote.value.repliesCount -= 1; - } + const replyIdx = replies.value.findIndex(note => note.id === id); + if (replyIdx >= 0) { + replies.value.splice(replyIdx, 1); + appearNote.value.repliesCount -= 1; + } } useNoteCapture({ @@ -165,7 +168,7 @@ useNoteCapture({ }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -193,8 +196,9 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.note.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -207,8 +211,8 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - os.api('notes/reactions/create', { + reactionPicker.show(reactButton.value ?? null, props.note, reaction => { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: reaction, }); @@ -224,7 +228,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -240,14 +245,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -269,42 +274,14 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -317,9 +294,9 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - channelId: props.note.channelId, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -333,10 +310,10 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - localOnly: visibility === 'local' ? true : false, - visibility: visibility === 'local' || visibility === 'specified' ? props.note.visibility : visibility, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + localOnly: localOnly, + visibility: visibility, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -353,7 +330,7 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -375,7 +352,7 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -404,7 +381,7 @@ function menu(viaKeyboard = false): void { } if (props.detail) { - os.api('notes/children', { + misskeyApi('notes/children', { noteId: props.note.id, limit: numberOfReplies.value, showQuotes: false, diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index fc1c8a0f09..afe43d965c 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only :ad="true" :class="$style.notes" > - <SkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + <SkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> </MkDateSeparatedList> </div> </template> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index ed79ca0d86..562cc38bf3 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -1,15 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root"> <div :class="$style.head"> - <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="notification.type === 'roleAssigned'" :class="$style.icon" :user="$i" link preview/> - <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ph-rocket-launch ph-bold ph-lg" style="line-height: 1;"></i></div> <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> @@ -26,8 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', + [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, + [$style.t_pollEnded]: notification.type === 'edited', }]" - > + > <!-- we re-use t_pollEnded for "edited" instead of making an identical style --> <i v-if="notification.type === 'follow'" class="ph-plus ph-bold ph-lg"></i> <i v-else-if="notification.type === 'receiveFollowRequest'" class="ph-clock ph-bold ph-lg"></i> <i v-else-if="notification.type === 'followRequestAccepted'" class="ph-check ph-bold ph-lg"></i> @@ -37,12 +37,16 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'quote'" class="ph-quotes ph-bold ph-lg"></i> <i v-else-if="notification.type === 'pollEnded'" class="ph-chart-bar-horizontal ph-bold ph-lg"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ph-trophy ph-bold ph-lg"></i> - <img v-else-if="notification.type === 'roleAssigned'" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> + <template v-else-if="notification.type === 'roleAssigned'"> + <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> + <i v-else class="ph-seal-check ph-bold ph-lg"></i> + </template> + <i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <MkReactionIcon v-else-if="notification.type === 'reaction'" :withTooltip="true" - :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')" :noStyle="true" style="width: 100%; height: 100%;" /> @@ -55,10 +59,11 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> - <MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> - <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.t('_notification.reactedBySomeUsers', { n: notification.reactions.length }) }}</span> - <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.t('_notification.renotedBySomeUsers', { n: notification.users.length }) }}</span> - <span v-else>{{ notification.header }}</span> + <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> + <span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: notification.reactions.length }) }}</span> + <span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span> + <span v-else-if="notification.type === 'app'">{{ notification.header }}</span> + <span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> </header> <div> @@ -97,7 +102,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <template v-else-if="notification.type === 'follow'"> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> - <div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div> </template> <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <template v-else-if="notification.type === 'receiveFollowRequest'"> @@ -113,12 +117,12 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <div v-if="notification.type === 'reaction:grouped'"> - <div v-for="reaction of notification.reactions" :class="$style.reactionsItem"> + <div v-for="reaction of notification.reactions" :key="reaction.user.id + reaction.reaction" :class="$style.reactionsItem"> <MkAvatar :class="$style.reactionsItemAvatar" :user="reaction.user" link preview/> <div :class="$style.reactionsItemReaction"> <MkReactionIcon :withTooltip="true" - :reaction="reaction.reaction ? reaction.reaction.replace(/^:(\w+):$/, ':$1@.:') : reaction.reaction" + :reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')" :noStyle="true" style="width: 100%; height: 100%;" /> @@ -126,10 +130,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else-if="notification.type === 'renote:grouped'"> - <div v-for="user of notification.users" :class="$style.reactionsItem"> + <div v-for="user of notification.users" :key="user.id" :class="$style.reactionsItem"> <MkAvatar :class="$style.reactionsItemAvatar" :user="user" link preview/> </div> </div> + + <MkA v-else-if="notification.type === 'edited'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> + <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> + </MkA> </div> </div> </div> @@ -139,16 +149,17 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; -import MkFollowButton from '@/components/MkFollowButton.vue'; import MkButton from '@/components/MkButton.vue'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { signinRequired } from '@/account.js'; import { infoImageUrl } from '@/instance.js'; +const $i = signinRequired(); + const props = withDefaults(defineProps<{ notification: Misskey.entities.Notification; withTime?: boolean; @@ -161,13 +172,15 @@ const props = withDefaults(defineProps<{ const followRequestDone = ref(false); const acceptFollowRequest = () => { + if (props.notification.user == null) return; followRequestDone.value = true; - os.api('following/requests/accept', { userId: props.notification.user.id }); + misskeyApi('following/requests/accept', { userId: props.notification.user.id }); }; const rejectFollowRequest = () => { + if (props.notification.user == null) return; followRequestDone.value = true; - os.api('following/requests/reject', { userId: props.notification.user.id }); + misskeyApi('following/requests/reject', { userId: props.notification.user.id }); }; </script> @@ -283,6 +296,12 @@ const rejectFollowRequest = () => { pointer-events: none; } +.t_roleAssigned { + padding: 3px; + background: #88a6b7; + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 6725776f43..71b38d99ed 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> </div> - <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> + <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.ts._notification._types[ntype] }}</MkSwitch> </div> </MkSpacer> </MkModalWindow> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index a157820d56..68bf1bf3d8 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,11 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notifications }"> <MkDateSeparatedList v-if="defaultStore.state.noteDesign === 'misskey'" v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id + ':note'" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> <MkDateSeparatedList v-else-if="defaultStore.state.noteDesign === 'sharkey'" v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <SkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> + <SkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id + ':note'" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> </template> @@ -40,6 +40,7 @@ import { notificationTypes } from '@/const.js'; import { infoImageUrl } from '@/instance.js'; import { defaultStore } from '@/store.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import * as Misskey from 'misskey-js'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; @@ -68,7 +69,7 @@ function onNotification(notification) { } if (!isMuted) { - pagingComponent.value.prepend(notification); + pagingComponent.value?.prepend(notification); } } @@ -80,17 +81,19 @@ function reload() { }); } -let connection; +let connection: Misskey.ChannelConnection<Misskey.Channels['main']>; onMounted(() => { connection = useStream().useChannel('main'); connection.on('notification', onNotification); + connection.on('notificationFlushed', reload); }); onActivated(() => { pagingComponent.value?.reload(); connection = useStream().useChannel('main'); connection.on('notification', onNotification); + connection.on('notificationFlushed', reload); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkNumber.vue b/packages/frontend/src/components/MkNumber.vue index aa04ab253b..a278205b61 100644 --- a/packages/frontend/src/components/MkNumber.vue +++ b/packages/frontend/src/components/MkNumber.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { reactive, watch } from 'vue'; -import gsap from 'gsap'; import number from '@/filters/number.js'; const props = defineProps<{ @@ -20,8 +19,24 @@ const tweened = reactive({ number: 0, }); -watch(() => props.value, (n) => { - gsap.to(tweened, { duration: 1, number: Number(n) || 0 }); +watch(() => props.value, (to, from) => { + // requestAnimationFrameを利用して、500msでfromからtoまでを1次関数的に変化させる + let start: number | null = null; + + function step(timestamp: number) { + if (start === null) { + start = timestamp; + } + const elapsed = timestamp - start; + tweened.number = (from ?? 0) + (to - (from ?? 0)) * elapsed / 500; + if (elapsed < 500) { + window.requestAnimationFrame(step); + } else { + tweened.number = to; + } + } + + window.requestAnimationFrame(step); }, { immediate: true, }); diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue index a98b6c4713..1825cc5405 100644 --- a/packages/frontend/src/components/MkNumberDiff.vue +++ b/packages/frontend/src/components/MkNumberDiff.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index aa05c43c0b..870599aa94 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue index 30ec896ce4..bb9122c976 100644 --- a/packages/frontend/src/components/MkObjectView.vue +++ b/packages/frontend/src/components/MkObjectView.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index 702bb95dc7..a0bc0c628e 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,7 +27,7 @@ const omitted = ref(false); const ignoreOmit = ref(false); const calcOmit = () => { - if (omitted.value || ignoreOmit.value) return; + if (omitted.value || ignoreOmit.value || content.value == null) return; omitted.value = content.value.offsetHeight > props.maxHeight; }; @@ -37,7 +37,7 @@ const omitObserver = new ResizeObserver((entries, observer) => { onMounted(() => { calcOmit(); - omitObserver.observe(content.value); + omitObserver.observe(content.value as HTMLElement); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue index 6c8a0e56a6..f6dc00698c 100644 --- a/packages/frontend/src/components/MkPagePreview.vue +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </header> <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> <footer> - <img class="icon" :src="page.user.avatarUrl"/> + <img v-if="page.user.avatarUrl" class="icon" :src="page.user.avatarUrl"/> <p>{{ userName(page.user) }}</p> </footer> </article> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 13a703e9f6..c3fa724a7a 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,33 +16,33 @@ SPDX-License-Identifier: AGPL-3.0-only @closed="$emit('closed')" > <template #header> - <template v-if="pageMetadata?.value"> - <i v-if="pageMetadata.value.icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> - <span>{{ pageMetadata.value.title }}</span> + <template v-if="pageMetadata"> + <i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageMetadata.title }}</span> </template> </template> <div ref="contents" :class="$style.root" style="container-type: inline-size;"> - <RouterView :key="reloadCount" :router="router"/> + <RouterView :key="reloadCount" :router="windowRouter"/> </div> </MkWindow> </template> <script lang="ts" setup> -import { ComputedRef, onMounted, onUnmounted, provide, shallowRef, ref, computed } from 'vue'; +import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; -import { mainRouter, routes, page } from '@/router.js'; -import { $i } from '@/account.js'; -import { Router, useScrollPositionManager } from '@/nirax.js'; +import { useScrollPositionManager } from '@/nirax.js'; import { i18n } from '@/i18n.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { useRouterFactory } from '@/router/supplier.js'; +import { mainRouter } from '@/router/main.js'; const props = defineProps<{ initialPath: string; @@ -52,17 +52,18 @@ defineEmits<{ (ev: 'closed'): void; }>(); -const router = new Router(routes, props.initialPath, !!$i, page(() => import('@/pages/not-found.vue'))); +const routerFactory = useRouterFactory(); +const windowRouter = routerFactory(props.initialPath); -const contents = shallowRef<HTMLElement>(); -const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const contents = shallowRef<HTMLElement | null>(null); +const pageMetadata = ref<null | PageMetadata>(null); const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); const history = ref<{ path: string; key: any; }[]>([{ - path: router.getCurrentPath(), - key: router.getCurrentKey(), + path: windowRouter.getCurrentPath(), + key: windowRouter.getCurrentKey(), }]); const buttonsLeft = computed(() => { - const buttons = []; + const buttons: Record<string, unknown>[] = []; if (history.value.length > 1) { buttons.push({ @@ -75,7 +76,7 @@ const buttonsLeft = computed(() => { }); const buttonsRight = computed(() => { const buttons = [{ - icon: 'ph-arrow-clockwise ph-bold ph-lg', + icon: 'ph-arrows-clockwise ph-bold ph-lg', title: i18n.ts.reload, onClick: reload, }, { @@ -88,14 +89,23 @@ const buttonsRight = computed(() => { }); const reloadCount = ref(0); -router.addListener('push', ctx => { +windowRouter.addListener('push', ctx => { history.value.push({ path: ctx.path, key: ctx.key }); }); -provide('router', router); -provideMetadataReceiver((info) => { +windowRouter.addListener('replace', ctx => { + history.value.pop(); + history.value.push({ path: ctx.path, key: ctx.key }); +}); + +windowRouter.init(); + +provide('router', windowRouter); +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); pageMetadata.value = info; }); +provideReactiveMetadata(pageMetadata); provide('shouldOmitHeaderTitle', true); provide('shouldHeaderThin', true); provide('forceSpacerMin', true); @@ -113,20 +123,20 @@ const contextmenu = computed(() => ([{ icon: 'ph-arrow-square-out ph-bold ph-lg', text: i18n.ts.openInNewTab, action: () => { - window.open(url + router.getCurrentPath(), '_blank', 'noopener'); - windowEl.value.close(); + window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener'); + windowEl.value?.close(); }, }, { icon: 'ph-link ph-bold ph-lg', text: i18n.ts.copyLink, action: () => { - copyToClipboard(url + router.getCurrentPath()); + copyToClipboard(url + windowRouter.getCurrentPath()); }, }])); function back() { history.value.pop(); - router.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); + windowRouter.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); } function reload() { @@ -134,20 +144,20 @@ function reload() { } function close() { - windowEl.value.close(); + windowEl.value?.close(); } function expand() { - mainRouter.push(router.getCurrentPath(), 'forcePage'); - windowEl.value.close(); + mainRouter.push(windowRouter.getCurrentPath(), 'forcePage'); + windowEl.value?.close(); } function popout() { - _popout(router.getCurrentPath(), windowEl.value.$el); - windowEl.value.close(); + _popout(windowRouter.getCurrentPath(), windowEl.value?.$el); + windowEl.value?.close(); } -useScrollPositionManager(() => getScrollContainer(contents.value), router); +useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); onMounted(() => { openingWindowsCount.value++; diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index bdd96238d3..62a85389ad 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -46,6 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js'; import { useDocumentVisibility } from '@/scripts/use-document-visibility.js'; import { defaultStore } from '@/store.js'; @@ -203,7 +204,7 @@ async function init(): Promise<void> { queue.value = new Map(); fetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await os.api(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: props.pagination.limit ?? 10, allowPartial: true, @@ -239,7 +240,7 @@ const fetchMore = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await os.api(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { @@ -303,7 +304,7 @@ const fetchMoreAhead = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; - await os.api(props.pagination.endpoint, { + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index 85dd402730..3c0cdaa786 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,7 +41,9 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const emit = defineEmits<{ (ev: 'done', v: { password: string; token: string | null; }): void; diff --git a/packages/frontend/src/components/MkPlusOneEffect.vue b/packages/frontend/src/components/MkPlusOneEffect.vue index a741a3f7a8..6c22edb943 100644 --- a/packages/frontend/src/components/MkPlusOneEffect.vue +++ b/packages/frontend/src/components/MkPlusOneEffect.vue @@ -1,11 +1,11 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> - <span class="text" :class="{ up }">+1</span> + <span class="text" :class="{ up }">+{{ value }}</span> </div> </template> @@ -16,7 +16,9 @@ import * as os from '@/os.js'; const props = withDefaults(defineProps<{ x: number; y: number; + value?: number | string; }>(), { + value: 1, }); const emit = defineEmits<{ @@ -40,6 +42,7 @@ onMounted(() => { <style lang="scss" module> .root { + user-select: none; pointer-events: none; position: fixed; width: 128px; diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 6ee0c44658..8c0804de04 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -1,29 +1,28 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="{ [$style.done]: closed || isVoted }"> <ul :class="$style.choices"> - <li v-for="(choice, i) in note.poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> + <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span :class="$style.fg"> <template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg" style="margin-right: 4px; color: var(--accent);"></i></template> <Mfm :text="choice.text" :plain="true"/> - <span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span> + <span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span> </span> </li> </ul> <p v-if="!readOnly" :class="$style.info"> - <span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span> - <span v-if="note.poll.multiple"> · </span> - <span v-if="note.poll.multiple" style="color: var(--accent); font-weight: bolder;">{{ i18n.ts._poll.multiple }}</span> + <span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span> + <span v-if="poll.multiple"> · </span> + <span v-if="poll.multiple" style="color: var(--accent); font-weight: bolder;">{{ i18n.ts._poll.multiple }}</span> <span> · </span> <a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> <span v-if="isVoted">{{ i18n.ts._poll.voted }}</span> <span v-else-if="closed">{{ i18n.ts._poll.closed }}</span> - <span v-if="!isLocal"><span> · </span><a @click.stop="refresh">{{ i18n.ts.reload }}</a></span> <span v-if="remaining > 0"> · {{ timer }}</span> </p> </div> @@ -35,36 +34,38 @@ import * as Misskey from 'misskey-js'; import { sum } from '@/scripts/array.js'; import { pleaseLogin } from '@/scripts/please-login.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@/scripts/use-interval.js'; const props = defineProps<{ - note: Misskey.entities.Note; + noteId: string; + poll: NonNullable<Misskey.entities.Note['poll']>; readOnly?: boolean; }>(); const remaining = ref(-1); -const total = computed(() => sum(props.note.poll.choices.map(x => x.votes))); +const total = computed(() => sum(props.poll.choices.map(x => x.votes))); const closed = computed(() => remaining.value === 0); -const isLocal = computed(() => !props.note.uri); -const isVoted = computed(() => !props.note.poll.multiple && props.note.poll.choices.some(c => c.isVoted)); -const timer = computed(() => i18n.t( - remaining.value >= 86400 ? '_poll.remainingDays' : - remaining.value >= 3600 ? '_poll.remainingHours' : - remaining.value >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { - s: Math.floor(remaining.value % 60), - m: Math.floor(remaining.value / 60) % 60, - h: Math.floor(remaining.value / 3600) % 24, - d: Math.floor(remaining.value / 86400), - })); +const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted)); +const timer = computed(() => i18n.tsx._poll[ + remaining.value >= 86400 ? 'remainingDays' : + remaining.value >= 3600 ? 'remainingHours' : + remaining.value >= 60 ? 'remainingMinutes' : 'remainingSeconds' +]({ + s: Math.floor(remaining.value % 60), + m: Math.floor(remaining.value / 60) % 60, + h: Math.floor(remaining.value / 3600) % 24, + d: Math.floor(remaining.value / 86400), +})); const showResult = ref(props.readOnly || isVoted.value); // 期限付きアンケート -if (props.note.poll.expiresAt) { +if (props.poll.expiresAt) { const tick = () => { - remaining.value = Math.floor(Math.max(new Date(props.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000); + remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000); if (remaining.value === 0) { showResult.value = true; } @@ -80,34 +81,26 @@ const vote = async (id) => { pleaseLogin(); if (props.readOnly || closed.value || isVoted.value) return; - if (!props.note.poll.multiple) { + if (!props.poll.multiple) { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }), + text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), }); if (canceled) return; } else { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('voteConfirmMulti', { choice: props.note.poll.choices[id].text }), + text: i18n.tsx.voteConfirmMulti({ choice: props.poll.choices[id].text }), }); if (canceled) return; } - await os.api('notes/polls/vote', { - noteId: props.note.id, + await misskeyApi('notes/polls/vote', { + noteId: props.noteId, choice: id, }); - if (!showResult.value) showResult.value = !props.note.poll.multiple; + if (!showResult.value) showResult.value = !props.poll.multiple; }; - -async function refresh() { - if (!props.note.uri) return; - const obj = await os.apiWithDialog('ap/show', { uri: props.note.uri }); - if (obj.type === 'Note' && obj.object.poll) { - props.note.poll = obj.object.poll; // eslint-disable-line vue/no-mutating-props - } -} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index f46779a632..98fbf25370 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </p> <ul> <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" small :modelValue="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> + <MkInput class="input" small :modelValue="choice" :placeholder="i18n.tsx._poll.choiceN({ n: i + 1 })" @update:modelValue="onInput(i, $event)"> </MkInput> <button class="_button" @click="remove(i)"> <i class="ph-x ph-bold ph-lg"></i> @@ -62,21 +62,18 @@ import { formatDateTimeString } from '@/scripts/format-time-string.js'; import { addTime } from '@/scripts/time.js'; import { i18n } from '@/i18n.js'; +export type PollEditorModelValue = { + expiresAt: number | null; + expiredAfter: number | null; + choices: string[]; + multiple: boolean; +}; + const props = defineProps<{ - modelValue: { - expiresAt: string; - expiredAfter: number; - choices: string[]; - multiple: boolean; - }; + modelValue: PollEditorModelValue; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', v: { - expiresAt: string; - expiredAfter: number; - choices: string[]; - multiple: boolean; - }): void; + (ev: 'update:modelValue', v: PollEditorModelValue): void; }>(); const choices = ref(props.modelValue.choices); @@ -89,7 +86,9 @@ const unit = ref('second'); if (props.modelValue.expiresAt) { expiration.value = 'at'; - atDate.value = atTime.value = props.modelValue.expiresAt; + const expiresAt = new Date(props.modelValue.expiresAt); + atDate.value = formatDateTimeString(expiresAt, 'yyyy-MM-dd'); + atTime.value = formatDateTimeString(expiresAt, 'HH:mm'); } else if (typeof props.modelValue.expiredAfter === 'number') { expiration.value = 'after'; after.value = props.modelValue.expiredAfter / 1000; @@ -113,20 +112,21 @@ function remove(i) { choices.value = choices.value.filter((_, _i) => _i !== i); } -function get() { +function get(): PollEditorModelValue { const calcAt = () => { return new Date(`${atDate.value} ${atTime.value}`).getTime(); }; const calcAfter = () => { - let base = parseInt(after.value); + let base = parseInt(after.value.toString()); switch (unit.value) { + // @ts-expect-error fallthrough case 'day': base *= 24; - // fallthrough + // @ts-expect-error fallthrough case 'hour': base *= 60; - // fallthrough + // @ts-expect-error fallthrough case 'minute': base *= 60; - // fallthrough + // eslint-disable-next-line no-fallthrough case 'second': return base *= 1000; default: return null; } @@ -135,10 +135,8 @@ function get() { return { choices: choices.value, multiple: multiple.value, - ...( - expiration.value === 'at' ? { expiresAt: calcAt() } : - expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} - ), + expiresAt: expiration.value === 'at' ? calcAt() : null, + expiredAfter: expiration.value === 'after' ? calcAfter() : null, }; } diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 1d92374f4f..3748f0cc64 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index aa37cef6c2..d9e50fbb79 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ph-eye-slash ph-bold ph-lg"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ph-at ph-bold ph-lg"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ph-hash ph-bold ph-lg"></i></button> - <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ph-plug ph-bold ph-lg"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ph-plug ph-bold ph-lg"></i></button> <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ph-smiley ph-bold ph-lg"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ph-palette ph-bold ph-lg"></i></button> </div> @@ -101,27 +101,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw } from 'vue'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode/'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; -import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue'; import { host, url } from '@/config.js'; import { erase, unique } from '@/scripts/array.js'; import { extractMentions } from '@/scripts/extract-mentions.js'; import { formatTimeString } from '@/scripts/format-time-string.js'; import { Autocomplete } from '@/scripts/autocomplete.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { selectFiles } from '@/scripts/select-file.js'; import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; +import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { uploadFile } from '@/scripts/upload.js'; import { deepClone } from '@/scripts/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; @@ -130,6 +131,8 @@ import { claimAchievement } from '@/scripts/achievements.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; +const $i = signinRequired(); + const modal = inject('modal'); const props = withDefaults(defineProps<{ @@ -137,13 +140,13 @@ const props = withDefaults(defineProps<{ renote?: Misskey.entities.Note; channel?: Misskey.entities.Channel; // TODO mention?: Misskey.entities.User; - specified?: Misskey.entities.User; + specified?: Misskey.entities.UserDetailed; initialText?: string; initialCw?: string; initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; - initialVisibleUsers?: Misskey.entities.User[]; + initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; instant?: boolean; fixed?: boolean; @@ -171,18 +174,13 @@ 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); const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); -const poll = ref<{ - choices: string[]; - multiple: boolean; - expiresAt: string | null; - expiredAfter: string | null; -} | null>(null); +const poll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); const showPreview = ref(defaultStore.state.showPreview); watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); @@ -309,7 +307,7 @@ if (props.reply && props.reply.text != null) { } } -if ($i?.isSilenced && visibility.value === 'public') { +if ($i.isSilenced && visibility.value === 'public') { visibility.value = 'home'; } @@ -330,15 +328,15 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib if (visibility.value === 'specified') { if (props.reply.visibleUserIds) { - os.api('users/show', { - userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), + misskeyApi('users/show', { + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId), }).then(users => { users.forEach(pushVisibleUser); }); } if (props.reply.userId !== $i.id) { - os.api('users/show', { userId: props.reply.userId }).then(user => { + misskeyApi('users/show', { userId: props.reply.userId }).then(user => { pushVisibleUser(user); }); } @@ -389,7 +387,7 @@ function addMissingMention() { for (const x of extractMentions(ast)) { if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { - os.api('users/show', { username: x.username, host: x.host }).then(user => { + misskeyApi('users/show', { username: x.username, host: x.host }).then(user => { visibleUsers.value.push(user); }); } @@ -466,9 +464,10 @@ function setVisibility() { os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility.value, - isSilenced: $i?.isSilenced, + isSilenced: $i.isSilenced, localOnly: localOnly.value, src: visibilityButton.value, + ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}), }, { changeVisibility: v => { visibility.value = v; @@ -537,7 +536,7 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } -function pushVisibleUser(user) { +function pushVisibleUser(user: Misskey.entities.UserDetailed) { if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.value.push(user); } @@ -579,10 +578,12 @@ function onCompositionEnd(ev: CompositionEvent) { async function onPaste(ev: ClipboardEvent) { if (props.mock) return; + if (!ev.clipboardData) return; - for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) { + for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { if (item.kind === 'file') { const file = item.getAsFile(); + if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; @@ -604,7 +605,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; }); } } @@ -635,26 +636,26 @@ function onDragover(ev) { } } -function onDragenter(ev) { +function onDragenter() { draghover.value = true; } -function onDragleave(ev) { +function onDragleave() { draghover.value = false; } -function onDrop(ev): void { +function onDrop(ev: DragEvent): void { draghover.value = false; // ファイルだったら - if (ev.dataTransfer.files.length > 0) { + if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { ev.preventDefault(); for (const x of Array.from(ev.dataTransfer.files)) upload(x); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); files.value.push(file); @@ -702,11 +703,14 @@ async function post(ev?: MouseEvent) { } if (ev) { - const el = ev.currentTarget ?? ev.target; - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); + const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; + + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } } if (props.mock) return; @@ -741,6 +745,29 @@ async function post(ev?: MouseEvent) { visibility.value = 'home'; } } + + if (defaultStore.state.warnMissingAltText) { + const filesData = toRaw(files.value); + + const isMissingAltText = filesData.some(file => !file.comment); + + if (isMissingAltText) { + const { canceled, result } = await os.actions({ + type: 'warning', + text: i18n.ts.thisPostIsMissingAltText, + actions: [{ + value: 'cancel', + text: i18n.ts.thisPostIsMissingAltTextCancel, + }, { + value: 'ignore', + text: i18n.ts.thisPostIsMissingAltTextIgnore, + }], + }); + + if (canceled) return; + if (result === 'cancel') return; + } + } let postData = { text: text.value === '' ? null : text.value, @@ -759,29 +786,39 @@ async function post(ev?: MouseEvent) { if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { const hashtags_ = hashtags.value.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); - postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_; + if (!postData.text) { + postData.text = hashtags_; + } else { + const postTextLines = postData.text.split('\n'); + if (postTextLines[postTextLines.length - 1].trim() === '') { + postTextLines[postTextLines.length - 1] += hashtags_; + } else { + postTextLines[postTextLines.length - 1] += ' ' + hashtags_; + } + postData.text = postTextLines.join('\n'); + } } // plugin if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { - postData = await interruptor.handler(deepClone(postData)); + postData = await interruptor.handler(deepClone(postData)) as typeof postData; } catch (err) { console.error(err); } } } - let token = undefined; + let token: string | undefined = undefined; if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value.id)?.token; + token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; } posting.value = true; - os.api(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { + misskeyApi(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { if (props.freezeAfterPosted) { posted.value = true; } else { @@ -791,7 +828,7 @@ async function post(ev?: MouseEvent) { deleteDraft(); emit('posted'); if (postData.text && postData.text !== '') { - const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const hashtags_ = mfm.parse(postData.text).map(x => x.type === 'hashtag' && x.props.hashtag).filter(x => x) as string[]; const history = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]') as string[]; miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); } @@ -854,16 +891,17 @@ function cancel() { } function insertMention() { - os.selectUser().then(user => { + os.selectUser({ localOnly: localOnly.value, includeSelf: true }).then(user => { insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' '); }); } async function insertEmoji(ev: MouseEvent) { textAreaReadOnly.value = true; - + const target = ev.currentTarget ?? ev.target; + if (target == null) return; emojiPicker.show( - ev.currentTarget ?? ev.target, + target as HTMLElement, emoji => { insertTextAtCursor(textareaEl.value, emoji); }, @@ -875,6 +913,7 @@ async function insertEmoji(ev: MouseEvent) { } async function insertMfmFunction(ev: MouseEvent) { + if (textareaEl.value == null) return; mfmFunctionPicker( ev.currentTarget ?? ev.target, textareaEl.value, @@ -882,14 +921,15 @@ async function insertMfmFunction(ev: MouseEvent) { ); } -function showActions(ev) { +function showActions(ev: MouseEvent) { os.popupMenu(postFormActions.map(action => ({ text: action.title, action: () => { action.handler({ text: text.value, cw: cw.value, - }, (key, value) => { + }, (key, value: any) => { + if (typeof key !== 'string') return; if (key === 'text') { text.value = value; } if (key === 'cw') { useCw.value = value !== null; cw.value = value; } }); @@ -926,9 +966,9 @@ onMounted(() => { } // TODO: detach when unmount - new Autocomplete(textareaEl.value, text); - new Autocomplete(cwInputEl.value, cw); - new Autocomplete(hashtagsInputEl.value, hashtags); + if (textareaEl.value) new Autocomplete(textareaEl.value, text); + if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw); + if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags); nextTick(() => { // 書きかけの投稿を復元 @@ -958,19 +998,19 @@ onMounted(() => { if (props.initialNote) { const init = props.initialNote; text.value = init.text ? init.text : ''; - files.value = init.files; - cw.value = init.cw; + files.value = init.files ?? []; + cw.value = init.cw ?? null; useCw.value = init.cw != null; if (init.poll) { poll.value = { choices: init.poll.choices.map(x => x.text), multiple: init.poll.multiple, - expiresAt: init.poll.expiresAt ? new Date(init.poll.expiresAt).getTime().toString() : null, - expiredAfter: init.poll.expiredAfter ? new Date(init.poll.expiredAfter).getTime().toString() : null, + expiresAt: init.poll.expiresAt ? (new Date(init.poll.expiresAt)).getTime() : null, + expiredAfter: null, }; } visibility.value = init.visibility; - localOnly.value = init.localOnly; + localOnly.value = init.localOnly ?? false; quoteId.value = init.renote ? init.renote.id : null; } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index b2597d090b..956dad8021 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,6 +24,7 @@ import { defineAsyncComponent, inject } from 'vue'; import * as Misskey from 'misskey-js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -55,13 +56,30 @@ function detachMedia(id: string) { } } +async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { + if (mock) return; + + detachMedia(file.id); + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('driveFileDeleteConfirm', { name: file.name }), + }); + + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: file.id, + }); +} + function toggleSensitive(file) { if (mock) { emit('changeSensitive', file, !file.isSensitive); return; } - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, }).then(() => { @@ -75,10 +93,10 @@ async function rename(file) { const { canceled, result } = await os.inputText({ title: i18n.ts.enterFileName, default: file.name, - allowEmpty: false, + minLength: 1, }); if (canceled) return; - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, name: result, }).then(() => { @@ -96,7 +114,7 @@ async function describe(file) { }, { done: caption => { let comment = caption.length === 0 ? null : caption; - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, comment: comment, }).then(() => { @@ -134,9 +152,16 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void { icon: 'ph-crop ph-bold ph-lg', action: () : void => { crop(file); }, }] : [], { + type: 'divider', + }, { text: i18n.ts.attachCancel, icon: 'ph-x-circle ph-bold ph-lg', action: () => { detachMedia(file.id); }, + }, { + text: i18n.ts.deleteFile, + icon: 'ph-trash ph-bold ph-lg', + danger: true, + action: () => { detachAndDeleteMedia(file); }, }], ev.currentTarget ?? ev.target).then(() => menuShowing = false); menuShowing = true; } diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index cd25077bfb..5260ac2a08 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -1,11 +1,11 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()"> - <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/> +<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()"> + <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/> </MkModal> </template> @@ -20,13 +20,13 @@ const props = defineProps<{ renote?: Misskey.entities.Note; channel?: any; // TODO mention?: Misskey.entities.User; - specified?: Misskey.entities.User; + specified?: Misskey.entities.UserDetailed; initialText?: string; initialCw?: string; - initialVisibility?: typeof Misskey.noteVisibilities; + initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; - initialVisibleUsers?: Misskey.entities.User[]; + initialVisibleUsers?: Misskey.entities.UserDetailed[]; initialNote?: Misskey.entities.Note; instant?: boolean; fixed?: boolean; @@ -42,7 +42,7 @@ const modal = shallowRef<InstanceType<typeof MkModal>>(); const form = shallowRef<InstanceType<typeof MkPostForm>>(); function onPosted() { - modal.value.close({ + modal.value?.close({ useSendAnimation: true, }); } diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index e963697997..b1ec440e42 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { i18n } from '@/i18n.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -129,7 +130,7 @@ function moveEnd() { function moving(event: TouchEvent | PointerEvent) { if (!isPullStart.value || isRefreshing.value || disabled) return; - if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value)) { + if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) { pullDistance.value = 0; isPullEnd.value = false; moveEnd(); @@ -148,6 +149,10 @@ function moving(event: TouchEvent | PointerEvent) { if (event.cancelable) event.preventDefault(); } + if (pullDistance.value > SCROLL_STOP) { + event.stopPropagation(); + } + isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; } diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index ebbd5e6cdc..5e42df4795 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -45,7 +45,8 @@ import { ref } from 'vue'; import { $i, getAccounts } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; import { instance } from '@/instance.js'; -import { api, apiWithDialog, promiseDialog } from '@/os.js'; +import { apiWithDialog, promiseDialog } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; defineProps<{ @@ -82,7 +83,7 @@ function subscribe() { pushSubscription.value = subscription; // Register - pushRegistrationInServer.value = await api('sw/register', { + pushRegistrationInServer.value = await misskeyApi('sw/register', { endpoint: subscription.endpoint, auth: encode(subscription.getKey('auth')), publickey: encode(subscription.getKey('p256dh')), @@ -125,7 +126,7 @@ async function unsubscribe() { } function encode(buffer: ArrayBuffer | null) { - return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + return btoa(String.fromCharCode.apply(null, buffer ? new Uint8Array(buffer) as any : [])); } /** @@ -159,7 +160,7 @@ if (navigator.serviceWorker == null) { supported.value = true; if (pushSubscription.value) { - const res = await api('sw/show-registration', { + const res = await misskeyApi('sw/show-registration', { endpoint: pushSubscription.value.endpoint, }); diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index edb3abe5f7..0b4023f254 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index d9178f3362..549438f61b 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,6 +18,9 @@ export default defineComponent({ watch(value, () => { context.emit('update:modelValue', value.value); }); + watch(() => props.modelValue, v => { + value.value = v; + }); if (!context.slots.default) return null; let options = context.slots.default(); const label = context.slots.label && context.slots.label(); @@ -35,7 +38,7 @@ export default defineComponent({ h('div', { class: 'body', }, options.map(option => h(MkRadio, { - key: option.key, + key: option.key as string, value: option.props?.value, modelValue: value.value, 'onUpdate:modelValue': _v => value.value = _v, diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index c1f5b6a790..46d76e2551 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: number): void; + (ev: 'dragEnded', value: number): void; }>(); const containerEl = shallowRef<HTMLElement>(); @@ -85,7 +86,7 @@ onMounted(() => { ro = new ResizeObserver((entries, observer) => { calcThumbPosition(); }); - ro.observe(containerEl.value); + if (containerEl.value) ro.observe(containerEl.value); }); onUnmounted(() => { @@ -121,7 +122,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { const onDrag = (ev: MouseEvent | TouchEvent) => { ev.preventDefault(); const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; + const pointerX = 'touches' in ev && ev.touches.length > 0 ? ev.touches[0].clientX : 'clientX' in ev ? ev.clientX : 0; const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2)); rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth))); @@ -143,6 +144,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { // 値が変わってたら通知 if (beforeValue !== finalValue.value) { emit('update:modelValue', finalValue.value); + emit('dragEnded', finalValue.value); } }; diff --git a/packages/frontend/src/components/MkReactionEffect.vue b/packages/frontend/src/components/MkReactionEffect.vue index 75eb91e7ad..361e246e9f 100644 --- a/packages/frontend/src/components/MkReactionEffect.vue +++ b/packages/frontend/src/components/MkReactionEffect.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index fdc3bfd23c..068a2968db 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/> +<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/> <MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/> </template> diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue index 8527b45347..15409a216a 100644 --- a/packages/frontend/src/components/MkReactionTooltip.vue +++ b/packages/frontend/src/components/MkReactionTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index 1b0d8f74a3..8b5e6efdf3 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,7 +44,7 @@ function getReactionName(reaction: string): string { if (trimLocal.startsWith(':')) { return trimLocal; } - return getEmojiName(reaction) ?? reaction; + return getEmojiName(reaction); } </script> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 09e864e497..2464d21b6a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,8 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only class="_button" :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" + @contextmenu.prevent.stop="menu" > - <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()"/> + <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()" @click.stop/> <span :class="$style.count">{{ count }}</span> </button> </template> @@ -19,9 +20,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { $i } from '@/account.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; @@ -29,6 +32,9 @@ import { claimAchievement } from '@/scripts/achievements.js'; 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 { customEmojisMap } from '@/custom-emojis.js'; +import { getUnicodeEmoji } from '@/scripts/emojilist.js'; const props = defineProps<{ reaction: string; @@ -45,13 +51,17 @@ const emit = defineEmits<{ const buttonEl = shallowRef<HTMLElement>(); -const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); +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); +}); +const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); async function toggleReaction() { if (!canToggle.value) return; - // TODO: その絵文字を使う権限があるかどうか確認 - const oldReaction = props.note.myReaction; if (oldReaction) { const confirm = await os.confirm({ @@ -61,7 +71,7 @@ async function toggleReaction() { if (confirm.canceled) return; if (oldReaction !== props.reaction) { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); } if (mock) { @@ -69,25 +79,25 @@ async function toggleReaction() { return; } - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: props.note.id, }).then(() => { if (oldReaction !== props.reaction) { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: props.reaction, }); } }); } else { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); return; } - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: props.reaction, }); @@ -97,9 +107,24 @@ async function toggleReaction() { } } +async function menu(ev) { + if (!canGetInfo.value) return; + + os.popupMenu([{ + text: i18n.ts.info, + icon: 'ph-info ph-bold ph-lg', + action: async () => { + os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: props.reaction.replace(/:/g, '').replace(/@\./, ''), + }), + }); + }, + }], ev.currentTarget ?? ev.target); +} + function anime() { - if (document.hidden) return; - if (!defaultStore.state.animation) return; + if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; @@ -117,7 +142,7 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { - const reactions = await os.apiGet('notes/reactions', { + const reactions = await misskeyApiGet('notes/reactions', { noteId: props.note.id, type: props.reaction, limit: 10, diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index d2a5c431fe..3d3130cd51 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkRemoteCaution.vue b/packages/frontend/src/components/MkRemoteCaution.vue index e8ca9260bb..5106cdfd6a 100644 --- a/packages/frontend/src/components/MkRemoteCaution.vue +++ b/packages/frontend/src/components/MkRemoteCaution.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index e69aa1be80..64b573c4d3 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, nextTick, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { alpha } from '@/scripts/color.js'; @@ -23,10 +23,9 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const now = new Date(); -let chartInstance: Chart = null; +const rootEl = shallowRef<HTMLDivElement | null>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); +let chartInstance: Chart | null = null; const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ @@ -34,6 +33,7 @@ const { handler: externalTooltipHandler } = useChartTooltip({ }); async function renderChart() { + if (rootEl.value == null) return; if (chartInstance) { chartInstance.destroy(); } @@ -43,11 +43,16 @@ async function renderChart() { const maxDays = wide ? 10 : narrow ? 5 : 7; - let raw = await os.api('retention', { }); + let raw = await misskeyApi('retention', { }); raw = raw.slice(0, maxDays + 1); - const data = []; + const data: { + x: number; + y: string; + v: number; + }[] = []; + for (const record of raw) { data.push({ x: 0, @@ -83,19 +88,20 @@ async function renderChart() { const marginEachCell = 12; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ label: 'Active', - data: data, - pointRadius: 0, + data: data as any, borderWidth: 0, - borderJoinStyle: 'round', borderRadius: 3, backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; - const m = max(c.dataset.data[c.dataIndex].y); + const v = c.dataset.data[c.dataIndex] as unknown as typeof data[0]; + const value = v.v; + const m = max(v.y); if (m === 0) { return alpha(color, 0); } else { @@ -103,7 +109,6 @@ async function renderChart() { return alpha(color, a); } }, - fill: true, width(c) { const a = c.chart.chartArea ?? {}; return (a.right - a.left) / maxDays - marginEachCell; @@ -146,7 +151,6 @@ async function renderChart() { }, y: { type: 'time', - min: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - maxDays), offset: true, reverse: true, position: 'left', @@ -179,7 +183,7 @@ async function renderChart() { return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000))); }, label(context) { - const v = context.dataset.data[context.dataIndex]; + const v = context.dataset.data[context.dataIndex] as unknown as typeof data[0]; const m = max(v.y); if (m === 0) { return [`Active: ${v.v} (-%)`]; diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index e2682ec06b..c3daa9c9a4 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,15 +16,15 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; import { alpha } from '@/scripts/color.js'; import { initChart } from '@/scripts/init-chart.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; const getYYYYMMDD = (date: Date) => { const y = date.getFullYear().toString().padStart(2, '0'); @@ -40,13 +40,15 @@ const getDate = (ymd: string) => { }; onMounted(async () => { - let raw = await os.api('retention', { }); + let raw = await misskeyApi('retention', { }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); const color = accent.toHex(); + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'line', data: { @@ -67,7 +69,7 @@ onMounted(async () => { x: (i + 1).toString(), y: (v / record.users) * 100, d: getYYYYMMDD(new Date(record.createdAt)), - }))], + }))] as any, })), }, options: { @@ -109,11 +111,11 @@ onMounted(async () => { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; + const v = context[0].dataset.data[context[0].dataIndex] as unknown as { x: string, y: number, d: string }; return `${v.x} days later`; }, label(context) { - const v = context.dataset.data[context.dataIndex]; + const v = context.dataset.data[context.dataIndex] as unknown as { x: string, y: number, d: string }; const p = Math.round(v.y) + '%'; return `${v.d} ${p}`; }, diff --git a/packages/frontend/src/components/MkRippleEffect.vue b/packages/frontend/src/components/MkRippleEffect.vue index 860b083327..ee5bb73ebf 100644 --- a/packages/frontend/src/components/MkRippleEffect.vue +++ b/packages/frontend/src/components/MkRippleEffect.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -77,7 +77,14 @@ const emit = defineEmits<{ (ev: 'end'): void; }>(); -const particles = []; +const particles: { + size: number; + xA: number; + yA: number; + xB: number; + yB: number; + color: string; +}[] = []; const origin = 64; const colors = ['#FF1493', '#00FFFF', '#FFE202']; const zIndex = os.claimZIndex('high'); diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index bd1767155b..f0343d499b 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 665ae2b813..ecac99ae45 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,16 +27,17 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue'; +import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; const props = defineProps<{ modelValue: string | null; @@ -52,7 +53,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'changeByUser', value: string | null): void; (ev: 'update:modelValue', value: string | null): void; }>(); @@ -74,10 +75,9 @@ const height = props.large ? 39 : 36; -const focus = () => inputEl.value.focus(); +const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; - emit('change', ev); }; const updated = () => { @@ -89,17 +89,19 @@ watch(modelValue, newValue => { v.value = newValue; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { updated(); } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { + if (inputEl.value == null) return; + if (prefixEl.value) { if (prefixEl.value.offsetWidth) { inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; @@ -123,35 +125,38 @@ onMounted(() => { }); }); -function show(ev: MouseEvent) { +function show() { focused.value = true; opening.value = true; - const menu = []; + const menu: MenuItem[] = []; let options = slots.default!(); const pushOption = (option: VNode) => { menu.push({ - text: option.children, - active: computed(() => v.value === option.props.value), + text: option.children as string, + active: computed(() => v.value === option.props?.value), action: () => { - v.value = option.props.value; + v.value = option.props?.value; + changed.value = true; + emit('changeByUser', v.value); }, }); }; - const scanOptions = (options: VNode[]) => { + const scanOptions = (options: VNodeChild[]) => { for (const vnode of options) { + if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; if (vnode.type === 'optgroup') { const optgroup = vnode; menu.push({ type: 'label', - text: optgroup.props.label, + text: optgroup.props?.label, }); - scanOptions(optgroup.children); + if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある const fragment = vnode; - scanOptions(fragment.children); + if (Array.isArray(fragment.children)) scanOptions(fragment.children); } else if (vnode.props == null) { // v-if で条件が false のときにこうなる // nop? } else { @@ -164,7 +169,7 @@ function show(ev: MouseEvent) { scanOptions(options); os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, + width: container.value?.offsetWidth, onClosing: () => { opening.value = false; }, @@ -284,6 +289,10 @@ function show(ev: MouseEvent) { padding-left: 6px; } +.save { + margin: 8px 0 0 0; +} + .chevron { transition: transform 0.1s ease-out; } diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index c884ce53ea..dc68a99593 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -59,6 +59,7 @@ import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import { host as configHost } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; @@ -95,7 +96,7 @@ const props = defineProps({ }); function onUsernameChange(): void { - os.api('users/show', { + misskeyApi('users/show', { username: username.value, }).then(userResponse => { user.value = userResponse; @@ -111,6 +112,7 @@ function onLogin(res: any): Promise<void> | void { } async function queryKey(): Promise<void> { + if (credentialRequest.value == null) return; queryingKey.value = true; await webAuthnRequest(credentialRequest.value) .catch(() => { @@ -120,7 +122,7 @@ async function queryKey(): Promise<void> { credentialRequest.value = null; queryingKey.value = false; signing.value = true; - return os.api('signin', { + return misskeyApi('signin', { username: username.value, password: password.value, credential: credential.toJSON(), @@ -142,7 +144,7 @@ function onSubmit(): void { signing.value = true; if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { if (webAuthnSupported() && user.value.securityKeys) { - os.api('signin', { + misskeyApi('signin', { username: username.value, password: password.value, }).then(res => { @@ -159,7 +161,7 @@ function onSubmit(): void { signing.value = false; } } else { - os.api('signin', { + misskeyApi('signin', { username: username.value, password: password.value, token: user.value?.twoFactorEnabled ? token.value : undefined, diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 6f961cff05..33355bb99e 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 9984b09c1a..7d03381a49 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -67,6 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #prefix><i class="ph-chalkboard-teacher ph-bold ph-lg"></i></template> </MkInput> <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> <MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;"> @@ -83,11 +84,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import { toUnicode } from 'punycode/'; +import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; import * as config from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; @@ -99,7 +102,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'signup', user: Record<string, any>): void; + (ev: 'signup', user: Misskey.entities.SigninResponse): void; (ev: 'signupEmailPending'): void; (ev: 'approvalPending'): void; }>(); @@ -122,6 +125,7 @@ const passwordStrength = ref<'' | 'low' | 'medium' | 'high'>(''); const passwordRetypeState = ref<null | 'match' | 'not-match'>(null); const submitting = ref<boolean>(false); const hCaptchaResponse = ref<string | null>(null); +const mCaptchaResponse = ref<string | null>(null); const reCaptchaResponse = ref<string | null>(null); const turnstileResponse = ref<string | null>(null); const usernameAbortController = ref<null | AbortController>(null); @@ -130,6 +134,7 @@ const emailAbortController = ref<null | AbortController>(null); const shouldDisableSubmitting = computed((): boolean => { return submitting.value || instance.enableHcaptcha && !hCaptchaResponse.value || + instance.enableMcaptcha && !mCaptchaResponse.value || instance.enableRecaptcha && !reCaptchaResponse.value || instance.enableTurnstile && !turnstileResponse.value || instance.emailRequiredForSignup && emailState.value !== 'ok' || @@ -186,7 +191,7 @@ function onChangeUsername(): void { usernameState.value = 'wait'; usernameAbortController.value = new AbortController(); - os.api('username/available', { + misskeyApi('username/available', { username: username.value, }, undefined, usernameAbortController.value.signal).then(result => { usernameState.value = result.available ? 'ok' : 'unavailable'; @@ -209,7 +214,7 @@ function onChangeEmail(): void { emailState.value = 'wait'; emailAbortController.value = new AbortController(); - os.api('email-address/available', { + misskeyApi('email-address/available', { emailAddress: email.value, }, undefined, emailAbortController.value.signal).then(result => { emailState.value = result.available ? 'ok' : @@ -251,20 +256,22 @@ async function onSubmit(): Promise<void> { submitting.value = true; try { - await os.api('signup', { + await misskeyApi('signup', { username: username.value, password: password.value, emailAddress: email.value, invitationCode: invitationCode.value, reason: reason.value, 'hcaptcha-response': hCaptchaResponse.value, + 'm-captcha-response': mCaptchaResponse.value, 'g-recaptcha-response': reCaptchaResponse.value, + 'turnstile-response': turnstileResponse.value, }); if (instance.emailRequiredForSignup) { os.alert({ type: 'success', title: i18n.ts._signup.almostThere, - text: i18n.t('_signup.emailSent', { email: email.value }), + text: i18n.tsx._signup.emailSent({ email: email.value }), }); emit('signupEmailPending'); } else if (instance.approvalRequiredForSignup) { @@ -275,7 +282,7 @@ async function onSubmit(): Promise<void> { }); emit('approvalPending'); } else { - const res = await os.api('signin', { + const res = await misskeyApi('signin', { username: username.value, password: password.value, }); diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts index ab26df6342..fcd1ffde3e 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkSignupServerRules from './MkSignupDialog.rules.vue'; diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index bc4fec305b..18a9eeda23 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix><i v-if="agreeServerRules" class="ph-check ph-bold ph-lg" style="color: var(--success)"></i></template> <ol class="_gaps_s" :class="$style.rules"> - <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li> + <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="sanitizeHtml(item)"></div></li> </ol> <MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch> @@ -34,8 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ tosPrivacyPolicyLabel }}</template> <template #suffix><i v-if="agreeTosAndPrivacyPolicy" class="ph-check ph-bold ph-lg" style="color: var(--success)"></i></template> <div class="_gaps_s"> - <div v-if="availableTos"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> - <div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> + <div v-if="availableTos"><a :href="instance.tosUrl ?? undefined" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> + <div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl ?? undefined" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a></div> </div> <MkSwitch :modelValue="agreeTosAndPrivacyPolicy" style="margin-top: 16px;" @update:modelValue="updateAgreeTosAndPrivacyPolicy">{{ i18n.ts.agree }}</MkSwitch> @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template> <template #suffix><i v-if="agreeNote" class="ph-check ph-bold ph-lg" style="color: var(--success)"></i></template> - <a href="https://git.joinsharkey.org/Sharkey/JoinSharkey/src/branch/main/IMPORTANT_NOTES.md" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a> + <a href="https://activitypub.software/TransFem-org/Sharkey/-/blob/stable/IMPORTANT_NOTES.md" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ph-arrow-square-out ph-bold ph-lg"></i></a> <MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch> </MkFolder> @@ -65,6 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import sanitizeHtml from 'sanitize-html'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -105,7 +106,7 @@ async function updateAgreeServerRules(v: boolean) { const confirm = await os.confirm({ type: 'question', title: i18n.ts.doYouAgree, - text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }), + text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.serverRules }), }); if (confirm.canceled) return; agreeServerRules.value = true; @@ -119,7 +120,7 @@ async function updateAgreeTosAndPrivacyPolicy(v: boolean) { const confirm = await os.confirm({ type: 'question', title: i18n.ts.doYouAgree, - text: i18n.t('iHaveReadXCarefullyAndAgree', { + text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: tosPrivacyPolicyLabel.value, }), }); @@ -135,7 +136,7 @@ async function updateAgreeNote(v: boolean) { const confirm = await os.confirm({ type: 'question', title: i18n.ts.doYouAgree, - text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }), + text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.basicNotesBeforeCreateAccount }), }); if (confirm.canceled) return; agreeNote.value = true; diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 6efdced69f..91e7d5dd53 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="500" :height="600" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" > <template #header>{{ i18n.ts.signup }}</template> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_x_leaveTo" > <template v-if="!isAcceptedServerRule"> - <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/> + <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/> </template> <template v-else> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending" @approvalPending="onApprovalPending"/> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef, ref } from 'vue'; - +import * as Misskey from 'misskey-js'; import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done'): void; + (ev: 'done', res: Misskey.entities.SigninResponse): void; (ev: 'closed'): void; }>(); @@ -55,13 +55,13 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const isAcceptedServerRule = ref(false); -function onSignup(res) { +function onSignup(res: Misskey.entities.SigninResponse) { emit('done', res); - dialog.value.close(); + dialog.value?.close(); } function onSignupEmailPending() { - dialog.value.close(); + dialog.value?.close(); } function onApprovalPending() { diff --git a/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue b/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue new file mode 100644 index 0000000000..7b936b656c --- /dev/null +++ b/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue @@ -0,0 +1,113 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_panel _shadow" :class="$style.root"> + <div :class="$style.icon"> + <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-open-source" width="40" height="40" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> + <path stroke="none" d="M0 0h24v24H0z" fill="none"/> + <path d="M12 3a9 9 0 0 1 3.618 17.243l-2.193 -5.602a3 3 0 1 0 -2.849 0l-2.193 5.603a9 9 0 0 1 3.617 -17.244z"/> + </svg> + </div> + <div :class="$style.main"> + <div :class="$style.title"> + <I18n :src="i18n.ts.aboutX" tag="span"> + <template #x> + {{ instance.name ?? host }} + </template> + </I18n> + </div> + <div :class="$style.text"> + <I18n :src="i18n.ts._aboutMisskey.thisIsModifiedVersion" tag="span"> + <template #name> + {{ instance.name ?? host }} + </template> + </I18n> + <I18n :src="i18n.ts.correspondingSourceIsAvailable" tag="span"> + <template #anchor> + <MkA to="/about-sharkey" class="_link">{{ i18n.ts.aboutMisskey }}</MkA> + </template> + </I18n> + </div> + <div class="_buttons"> + <MkButton @click="close">{{ i18n.ts.gotIt }}</MkButton> + </div> + </div> + <button class="_button" :class="$style.close" @click="close"><i class="ph-x ph-bold ph-lg"></i></button> +</div> +</template> + +<script lang="ts" setup> +import MkButton from '@/components/MkButton.vue'; +import { host } from '@/config.js'; +import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import { miLocalStorage } from '@/local-storage.js'; +import * as os from '@/os.js'; + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const zIndex = os.claimZIndex('low'); + +function close() { + miLocalStorage.setItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read', 'true'); + emit('closed'); +} +</script> + +<style lang="scss" module> +.root { + position: fixed; + z-index: v-bind(zIndex); + bottom: var(--margin); + left: 0; + right: 0; + margin: auto; + box-sizing: border-box; + width: calc(100% - (var(--margin) * 2)); + max-width: 500px; + display: flex; + backdrop-filter: var(--blur, blur(15px)); +} + +.icon { + text-align: center; + padding-top: 25px; + width: 100px; + color: var(--accent); +} +@media (max-width: 500px) { + .icon { + width: 80px; + } +} +@media (max-width: 450px) { + .icon { + width: 70px; + } +} + +.main { + padding: 25px 25px 25px 0; + flex: 1; +} + +.close { + position: absolute; + top: 8px; + right: 8px; + padding: 8px; +} + +.title { + font-weight: bold; +} + +.text { + margin: 0.7em 0 1em 0; +} +</style> diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index 269825e25e..8491ce2f84 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -89,10 +89,11 @@ let ro: ResizeObserver | undefined; onMounted(() => { ro = new ResizeObserver((entries, observer) => { - width.value = el.value?.offsetWidth + 64; - height.value = el.value?.offsetHeight + 64; + if (el.value == null) return; + width.value = el.value.offsetWidth + 64; + height.value = el.value.offsetHeight + 64; }); - ro.observe(el.value); + if (el.value) ro.observe(el.value); const add = () => { if (stop) return; const x = (Math.random() * (width.value - 64)); diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index c071fb938a..7e63bbe82d 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -1,13 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="[$style.root, { [$style.collapsed]: collapsed }]"> - <div :class="{ [$style.clickToOpen]: defaultStore.state.clickToOpen }" @click="defaultStore.state.clickToOpen ? noteclick(note.id) : undefined"> + <div :class="{ [$style.clickToOpen]: defaultStore.state.clickToOpen }" @click.stop="defaultStore.state.clickToOpen ? noteclick(note.id) : undefined"> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> <MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> @@ -15,40 +15,40 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="note.text && translating || note.text && translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> </div> </div> <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA> </div> - <details v-if="note.files.length > 0" :open="!defaultStore.state.collapseFiles && !hideFiles"> - <summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary> + <details v-if="note.files && note.files.length > 0" :open="!defaultStore.state.collapseFiles && !hideFiles"> + <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary> <MkMediaList :mediaList="note.files"/> </details> <details v-if="note.poll"> <summary>{{ i18n.ts.poll }}</summary> - <MkPoll :note="note"/> + <MkPoll :noteId="note.id" :poll="note.poll"/> </details> - <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> + <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click.stop="collapsed = false"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> - <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click.stop="collapsed = true"> <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> </button> </div> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; @@ -57,6 +57,7 @@ const props = defineProps<{ translating?: boolean; translation?: any; hideFiles?: boolean; + expandAllCws?: boolean; }>(); const router = useRouter(); @@ -87,6 +88,10 @@ function animatedMFM() { } const collapsed = ref(isLong); + +watch(() => props.expandAllCws, (expandAllCws) => { + if (expandAllCws) collapsed.value = false; +}); </script> <style lang="scss" module> @@ -165,5 +170,6 @@ const collapsed = ref(isLong); .clickToOpen { cursor: pointer; + -webkit-tap-highlight-color: transparent; } </style> diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 93296dd9d5..2a7c72ccd9 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index b82f36cdd3..21339d1b4e 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,7 +24,7 @@ import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ checked: boolean | Ref<boolean>; - disabled?: boolean; + disabled?: boolean | Ref<boolean>; }>(), { disabled: false, }); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 35e5aebbdd..5672c8e9f7 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue index 2b56b946d2..54ab8fc663 100644 --- a/packages/frontend/src/components/MkTab.vue +++ b/packages/frontend/src/components/MkTab.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,18 +13,18 @@ export default defineComponent({ }, }, setup(props, { emit, slots }) { - const options = slots.default(); + const options = slots.default?.() ?? []; return () => h('div', { class: 'pxhvhrfw', }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: props.modelValue === option.props.value }], - key: option.key, - disabled: props.modelValue === option.props.value, + class: ['_button', { active: props.modelValue === option.props?.value }], + key: option.key as string, + disabled: props.modelValue === option.props?.value, onClick: () => { - emit('update:modelValue', option.props.value); + emit('update:modelValue', option.props?.value); }, - }, option.children), [ + }, option.children ?? []), [ [resolveDirective('click-anime')], ]))); }, diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 083c34906f..6b9c181597 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -52,7 +52,7 @@ watch(available, () => { }); onMounted(() => { - width.value = rootEl.value.offsetWidth; + if (rootEl.value) width.value = rootEl.value.offsetWidth; if (loaded) { available.value = true; diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 5c70adde11..3082842699 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only :readonly="readonly" :placeholder="placeholder" :pattern="pattern" - :autocomplete="props.autocomplete" + :autocomplete="autocomplete" :spellcheck="spellcheck" @focus="focused = true" @blur="focused = false" @@ -76,9 +76,9 @@ const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLTextAreaElement>(); const preview = ref(false); -let autocomplete: Autocomplete; +let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value.focus(); +const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; emit('change', ev); @@ -111,10 +111,10 @@ const updated = () => { const debouncedUpdated = debounce(1000, updated); watch(modelValue, newValue => { - v.value = newValue; + v.value = newValue ?? ''; }); -watch(v, newValue => { +watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); @@ -123,7 +123,7 @@ watch(v, newValue => { } } - invalid.value = inputEl.value.validity.badInput; + invalid.value = inputEl.value?.validity.badInput ?? true; }); onMounted(() => { @@ -133,14 +133,14 @@ onMounted(() => { } }); - if (props.mfmAutocomplete) { - autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + if (props.mfmAutocomplete && inputEl.value) { + autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { - if (autocomplete) { - autocomplete.detach(); + if (autocompleteWorker) { + autocompleteWorker.detach(); } }); </script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 8bd68c0fd2..1c14174a37 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,14 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only :pagination="paginationQuery" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" @queue="emit('queue', $event)" - @status="prComponent.setDisabled($event)" + @status="prComponent?.setDisabled($event)" /> </MkPullToRefresh> </template> <script lang="ts" setup> -import { computed, watch, onUnmounted, provide, ref } from 'vue'; -import { Connection } from 'misskey-js/built/streaming.js'; +import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; +import * as Misskey from 'misskey-js'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; @@ -29,7 +29,7 @@ import { defaultStore } from '@/store.js'; import { Paging } from '@/components/MkPagination.vue'; const props = withDefaults(defineProps<{ - src: string; + src: 'home' | 'local' | 'social' | 'bubble' | 'global' | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; list?: string; antenna?: string; channel?: string; @@ -51,6 +51,7 @@ const emit = defineEmits<{ (ev: 'queue', count: number): void; }>(); +provide('inTimeline', true); provide('inChannel', computed(() => props.src === 'channel')); type TimelineQueryType = { @@ -65,12 +66,14 @@ type TimelineQueryType = { roleId?: string } -const prComponent = ref<InstanceType<typeof MkPullToRefresh>>(); -const tlComponent = ref<InstanceType<typeof MkNotes>>(); +const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>(); +const tlComponent = shallowRef<InstanceType<typeof MkNotes>>(); let tlNotesCount = 0; -const prepend = note => { +function prepend(note) { + if (tlComponent.value == null) return; + tlNotesCount++; if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) { @@ -82,18 +85,19 @@ const prepend = note => { emit('note'); if (props.sound) { - sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); } -}; +} -let connection: Connection; -let connection2: Connection; +let connection: Misskey.ChannelConnection | null = null; +let connection2: Misskey.ChannelConnection | null = null; let paginationQuery: Paging | null = null; const stream = useStream(); function connectChannel() { if (props.src === 'antenna') { + if (props.antenna == null) return; connection = stream.useChannel('antenna', { antennaId: props.antenna, }); @@ -141,20 +145,24 @@ function connectChannel() { connection = stream.useChannel('main'); connection.on('mention', onNote); } else if (props.src === 'list') { + if (props.list == null) return; connection = stream.useChannel('userList', { + withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); } else if (props.src === 'channel') { + if (props.channel == null) return; connection = stream.useChannel('channel', { channelId: props.channel, }); } else if (props.src === 'role') { + if (props.role == null) return; connection = stream.useChannel('roleTimeline', { roleId: props.role, }); } - if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend); + if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend); } function disconnectChannel() { @@ -163,7 +171,7 @@ function disconnectChannel() { } function updatePaginationQuery() { - let endpoint: string | null; + let endpoint: keyof Misskey.Endpoints | null; let query: TimelineQueryType | null; if (props.src === 'antenna') { @@ -219,6 +227,7 @@ function updatePaginationQuery() { } else if (props.src === 'list') { endpoint = 'notes/user-list-timeline'; query = { + withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }; @@ -257,8 +266,9 @@ function refreshEndpointAndChannel() { updatePaginationQuery(); } +// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる // IDが切り替わったら切り替え先のTLを表示させたい -watch(() => [props.list, props.antenna, props.channel, props.role], refreshEndpointAndChannel); +watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); // 初回表示用 refreshEndpointAndChannel(); @@ -269,6 +279,8 @@ onUnmounted(() => { function reloadTimeline() { return new Promise<void>((res) => { + if (tlComponent.value == null) return; + tlNotesCount = 0; tlComponent.value.pagingComponent?.reload().then(() => { diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 82cd236193..a117e49350 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 8e8e26ed5f..b32066c950 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withOkButton="true" :okButtonDisabled="false" :canClose="false" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" @ok="ok()" > @@ -33,7 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> </div> <div class="_gaps_s"> - <MkSwitch v-for="kind in Object.keys(permissions)" :key="kind" v-model="permissions[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch> + <MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch> + </div> + <div v-if="iAmAdmin" :class="$style.adminPermissions"> + <div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div> + <div class="_gaps_s"> + <MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch> + </div> </div> </div> </MkSpacer> @@ -49,6 +55,7 @@ import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; +import { iAmAdmin } from '@/account.js'; const props = withDefaults(defineProps<{ title?: string | null; @@ -68,37 +75,76 @@ const emit = defineEmits<{ }>(); const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); +const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin')); + const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const name = ref(props.initialName); -const permissions = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitches = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitchesForAdmin = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); if (props.initialPermissions) { for (const kind of props.initialPermissions) { - permissions.value[kind] = true; + permissionSwitches.value[kind] = true; } } else { for (const kind of defaultPermissions) { - permissions.value[kind] = false; + permissionSwitches.value[kind] = false; + } + + if (iAmAdmin) { + for (const kind of adminPermissions) { + permissionSwitchesForAdmin.value[kind] = false; + } } } function ok(): void { emit('done', { name: name.value, - permissions: Object.keys(permissions.value).filter(p => permissions.value[p]), + permissions: [ + ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]), + ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), + ], }); - dialog.value.close(); + dialog.value?.close(); } function disableAll(): void { - for (const p in permissions.value) { - permissions.value[p] = false; + for (const p in permissionSwitches.value) { + permissionSwitches.value[p] = false; + } + if (iAmAdmin) { + for (const p in permissionSwitchesForAdmin.value) { + permissionSwitchesForAdmin.value[p] = false; + } } } function enableAll(): void { - for (const p in permissions.value) { - permissions.value[p] = true; + for (const p in permissionSwitches.value) { + permissionSwitches.value[p] = true; + } + if (iAmAdmin) { + for (const p in permissionSwitchesForAdmin.value) { + permissionSwitchesForAdmin.value[p] = true; + } } } </script> + +<style module lang="scss"> +.adminPermissions { + margin: 8px -6px 0; + padding: 24px 6px 6px; + border: 2px solid var(--error); + border-radius: calc(var(--radius) / 2); +} + +.adminPermissionsHeader { + margin: -34px 0 6px 12px; + padding: 0 4px; + width: fit-content; + color: var(--error); + background: var(--panel); +} +</style> diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index eeb9325a29..aac07008a4 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,8 +13,10 @@ SPDX-License-Identifier: AGPL-3.0-only > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> <slot> - <Mfm v-if="asMfm" :text="text"/> - <span v-else>{{ text }}</span> + <template v-if="text"> + <Mfm v-if="asMfm" :text="text"/> + <span v-else>{{ text }}</span> + </template> </slot> </div> </Transition> @@ -53,6 +55,7 @@ const el = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex('high'); function setPosition() { + if (el.value == null) return; const data = calcPopupPosition(el.value, { anchorElement: props.targetElement, direction: props.direction, diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 3fca958055..5544434b5f 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="phase === 'howToReact'" class="_gaps"> <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div> <div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div> - <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction" @updateReaction="updateReaction"/> + <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction"/> <div v-if="onceReacted"><b style="color: var(--accent);"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div> </div> </template> @@ -53,7 +53,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ isBot: false, isCat: true, emojis: {}, - onlineStatus: null, + onlineStatus: 'unknown', badgeRoles: [], }, text: 'just setting up my shonk', @@ -86,7 +86,6 @@ function doNotification(emoji: string): void { const notification: Misskey.entities.Notification = { id: Math.random().toString(), createdAt: new Date().toUTCString(), - isRead: false, type: 'reaction', reaction: emoji, user: $i, diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index f093d6d9ef..1771559a9b 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -58,7 +58,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({ isBot: false, isCat: true, emojis: {}, - onlineStatus: null, + onlineStatus: 'unknown', badgeRoles: [], }, text: i18n.ts._initialTutorial._postNote._cw._exampleNote.note, diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index dd255a2214..4b4e8ea8f8 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -40,7 +40,7 @@ const emit = defineEmits<{ const onceSucceeded = ref<boolean>(false); function doSucceeded(fileId: string, to: boolean) { - if (fileId === exampleNote.fileIds[0] && to) { + if (fileId === exampleNote.fileIds?.[0] && to) { onceSucceeded.value = true; emit('succeeded'); } diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue index c2384423fd..f5670c7ebd 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index a734f93ec9..6cd7019fed 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only @close="close(true)" @closed="emit('closed')" > - <template v-if="page === 1" #header><i class="ph-pencil ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._note.title }}</template> + <template v-if="page === 1" #header><i class="ph-pencil-simple ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._note.title }}</template> <template v-else-if="page === 2" #header><i class="ph-smiley ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._reaction.title }}</template> <template v-else-if="page === 3" #header><i class="ph-house ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._timeline.title }}</template> <template v-else-if="page === 4" #header><i class="ph-plus ph-bold pg-lg"></i> {{ i18n.ts._initialTutorial._postNote.title }}</template> @@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only <a href="https://misskey-hub.net/docs/for-users/" target="_blank" class="_link">{{ i18n.ts.help }}</a> </template> </I18n> - <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div> + <div>{{ i18n.tsx._initialAccountSetting.haveFun({ name: instance.name ?? host }) }}</div> <div class="_buttonsCenter" style="margin-top: 16px;"> <MkButton v-if="initialPage !== 4" rounded @click="page--"><i class="ph-arrow-left ph-bold pg-lg"></i> {{ i18n.ts.goBack }}</MkButton> <MkButton rounded primary gradate @click="close(false)">{{ i18n.ts.close }}</MkButton> diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 07efaf8982..4fb0749931 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -1,15 +1,15 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> +<MkModal ref="modal" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> <MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton> - <MkButton :class="$style.gotIt" primary full @click="$refs.modal.close()">{{ i18n.ts.gotIt }}</MkButton> + <MkButton :class="$style.gotIt" primary full @click="modal?.close()">{{ i18n.ts.gotIt }}</MkButton> </div> </MkModal> </template> @@ -26,8 +26,8 @@ import { confetti } from '@/scripts/confetti.js'; const modal = shallowRef<InstanceType<typeof MkModal>>(); const whatIsNew = () => { - modal.value.close(); - window.open(`https://git.joinsharkey.org/Sharkey/Sharkey/releases/tag/${version}`, '_blank'); + modal.value?.close(); + window.open(`https://activitypub.software/TransFem-org/Sharkey/-/releases/${version}`, '_blank'); }; onMounted(() => { diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 486aaa0bbd..10ba137b94 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin" scrolling="no" - :allow="player.allow.join(';')" + :allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :style="{ border: 0 }" @@ -83,8 +83,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, onUnmounted, ref } from 'vue'; -import type { summaly } from 'summaly'; +import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; +import type { summaly } from '@misskey-dev/summaly'; import { url as local } from '@/config.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -131,6 +131,10 @@ const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; const tweetHeight = ref(150); const unknownUrl = ref(false); +onDeactivated(() => { + playerEnabled.value = false; +}); + const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index 81c383540c..cf75064be7 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 3fbadbe34f..13ab6fd763 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkModalWindow ref="dialog" :width="400" - @close="dialog.close()" + @close="dialog?.close()" @closed="$emit('closed')" > <template v-if="announcement" #header>:{{ announcement.title }}:</template> @@ -56,6 +56,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -63,14 +64,14 @@ import MkRadios from '@/components/MkRadios.vue'; const props = defineProps<{ user: Misskey.entities.User, - announcement?: any, + announcement?: Misskey.entities.Announcement, }>(); const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); -const title = ref<string>(props.announcement ? props.announcement.title : ''); -const text = ref<string>(props.announcement ? props.announcement.text : ''); -const icon = ref<string>(props.announcement ? props.announcement.icon : 'info'); -const display = ref<string>(props.announcement ? props.announcement.display : 'dialog'); +const title = ref(props.announcement ? props.announcement.title : ''); +const text = ref(props.announcement ? props.announcement.text : ''); +const icon = ref(props.announcement ? props.announcement.icon : 'info'); +const display = ref(props.announcement ? props.announcement.display : 'dialog'); const needConfirmationToRead = ref(props.announcement ? props.announcement.needConfirmationToRead : false); const emit = defineEmits<{ @@ -91,18 +92,18 @@ async function done() { if (props.announcement) { await os.apiWithDialog('admin/announcements/update', { - id: props.announcement.id, ...params, + id: props.announcement.id, }); emit('done', { updated: { - id: props.announcement.id, ...params, + id: props.announcement.id, }, }); - dialog.value.close(); + dialog.value?.close(); } else { const created = await os.apiWithDialog('admin/announcements/create', params); @@ -110,25 +111,27 @@ async function done() { created: created, }); - dialog.value.close(); + dialog.value?.close(); } } async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: title.value }), + text: i18n.tsx.removeAreYouSure({ x: title.value }), }); if (canceled) return; - os.api('admin/announcements/delete', { - id: props.announcement.id, - }).then(() => { - emit('done', { - deleted: true, + if (props.announcement) { + await misskeyApi('admin/announcements/delete', { + id: props.announcement.id, }); - dialog.value.close(); + } + + emit('done', { + deleted: true, }); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index b9c7377972..603f9f2435 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -1,16 +1,16 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-adaptive-bg :class="[$style.root, { yellow: user.isSilenced, blue: !user.approved, red: user.isSuspended, gray: false }]"> - <MkAvatar class="avatar" :user="user" indicator/> - <div class="body"> - <span class="name"><MkUserName class="name" :user="user"/></span> - <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> +<div v-adaptive-bg :class="[$style.root]"> + <MkAvatar :class="$style.avatar" :user="user" indicator/> + <div :class="$style.body"> + <span :class="$style.name"><MkUserName :user="user"/></span> + <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span> </div> - <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> + <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> </div> </template> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { acct } from '@/filters/user.js'; const props = withDefaults(defineProps<{ @@ -32,7 +32,7 @@ const chartValues = ref<number[] | null>(null); onMounted(() => { if (props.withChart) { - os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { + misskeyApiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く res.inc.splice(0, 1); chartValues.value = res.inc; @@ -42,77 +42,53 @@ onMounted(() => { </script> <style lang="scss" module> -.root { - $bodyTitleHieght: 18px; - $bodyInfoHieght: 16px; +$bodyTitleHieght: 18px; +$bodyInfoHieght: 16px; +.root { display: flex; align-items: center; padding: 16px; background: var(--panel); border-radius: var(--radius-sm); +} - > :global(.avatar) { - display: block; - width: ($bodyTitleHieght + $bodyInfoHieght); - height: ($bodyTitleHieght + $bodyInfoHieght); - margin-right: 12px; - } - - > :global(.body) { - flex: 1; - overflow: hidden; - font-size: 0.9em; - color: var(--fg); - padding-right: 8px; - - > :global(.name) { - display: block; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: $bodyTitleHieght; - } - - > :global(.sub) { - display: block; - width: 100%; - font-size: 95%; - opacity: 0.7; - line-height: $bodyInfoHieght; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - > :global(.chart) { - height: 30px; - } +.avatar { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; +} - &:global(.yellow) { - --c: rgb(255 196 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; +} - &:global(.blue) { - --c: rgba(0 153 255 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; +} - &:global(.red) { - --c: rgb(255 0 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.sub { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} - &:global(.gray) { - --c: var(--bg); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - } +.chart { + height: 30px; } </style> diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 4e326911d8..63c4af41a0 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -65,8 +65,8 @@ defineProps<{ top: 62px; left: 13px; z-index: 2; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); border: solid 4px var(--panel); } diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 56a61dce23..17a9254d01 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue index 76470cba88..9f04353f62 100644 --- a/packages/frontend/src/components/MkUserOnlineIndicator.vue +++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index ec2c48b1cf..6550fc4ec1 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only </dt> <dd :class="$style.fieldvalue"> <Mfm :text="field.value" :nyaize="false" :author="user" :colored="false"/> - <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg" :class="$style.verifiedLink"></i> + <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg"></i> </dd> </dl> </div> @@ -72,6 +72,7 @@ import * as Misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; @@ -97,6 +98,7 @@ const top = ref(0); const left = ref(0); function showMenu(ev: MouseEvent) { + if (user.value == null) return; const { menu, cleanup } = getUserMenu(user.value); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } @@ -109,7 +111,7 @@ onMounted(() => { Misskey.acct.parse(props.q.substring(1)) : { userId: props.q }; - os.api('users/show', query).then(res => { + misskeyApi('users/show', query).then(res => { if (!props.showing) return; user.value = res; }); @@ -199,8 +201,8 @@ onMounted(() => { right: 0; margin: 0 auto; z-index: 2; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .title { diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 9d41147bd2..b76be051d8 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>{{ i18n.ts.selectUser }}</template> <div> <div :class="$style.form"> - <FormSplit :minWidth="170"> + <MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search"> + <template #label>{{ i18n.ts.username }}</template> + <template #prefix>@</template> + </MkInput> + <FormSplit v-else :minWidth="170"> <MkInput v-model="username" :autofocus="true" @update:modelValue="search"> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> @@ -62,11 +66,11 @@ import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { hostname } from '@/config.js'; +import { host as currentHost, hostname } from '@/config.js'; const emit = defineEmits<{ (ev: 'ok', selected: Misskey.entities.UserDetailed): void; @@ -74,58 +78,85 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const props = defineProps<{ +const props = withDefaults(defineProps<{ includeSelf?: boolean; -}>(); + localOnly?: boolean; +}>(), { + includeSelf: false, + localOnly: false, +}); const username = ref(''); const host = ref(''); -const users = ref<Misskey.entities.UserDetailed[]>([]); +const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); -const selected = ref<Misskey.entities.UserDetailed | null>(null); +const selected = ref<Misskey.entities.UserLite | null>(null); const dialogEl = ref(); -const search = () => { +function search() { if (username.value === '' && host.value === '') { users.value = []; return; } - os.api('users/search-by-username-and-host', { + + misskeyApi('users/search-by-username-and-host', { username: username.value, - host: host.value, + host: props.localOnly ? '.' : host.value, limit: 10, detail: false, }).then(_users => { - users.value = _users; + users.value = _users.filter((u) => { + if (props.includeSelf) { + return true; + } else { + return u.id !== $i?.id; + } + }); }); -}; +} -const ok = () => { +async function ok() { if (selected.value == null) return; - emit('ok', selected.value); + + const user = await misskeyApi('users/show', { + userId: selected.value.id, + }); + emit('ok', user); + dialogEl.value.close(); // 最近使ったユーザー更新 let recents = defaultStore.state.recentlyUsedUsers; - recents = recents.filter(x => x !== selected.value.id); + recents = recents.filter(x => x !== selected.value?.id); recents.unshift(selected.value.id); defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); -}; +} -const cancel = () => { +function cancel() { emit('cancel'); dialogEl.value.close(); -}; +} onMounted(() => { - os.api('users/show', { + misskeyApi('users/show', { userIds: defaultStore.state.recentlyUsedUsers, - }).then(users => { - if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) { - recentUsers.value = [$i, ...users]; - } else { - recentUsers.value = users; - } + }).then(foundUsers => { + let _users = foundUsers; + _users = _users.filter((u) => { + if (props.localOnly) { + return u.host == null; + } else { + return true; + } + }); + _users = _users.filter((u) => { + if (props.includeSelf) { + return true; + } else { + return u.id !== $i?.id; + } + }); + recentUsers.value = _users; }); }); </script> @@ -133,7 +164,7 @@ onMounted(() => { <style lang="scss" module> .form { - padding: 0 var(--root-margin); + padding: calc(var(--root-margin) / 2) var(--root-margin); } .result, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts index 45c7da40ce..638bfb4372 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue'; @@ -38,17 +38,17 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), - rest.post('/api/pinned-users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/pinned-users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 5f3f5b81dd..1524ea0ec9 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pinnedUsers"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in items" :key="item.id" :user="item"/> + <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="popularUsers"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in items" :key="item.id" :user="item"/> + <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -34,18 +34,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; -const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; +const pinnedUsers: Paging = { + endpoint: 'pinned-users', + noPaging: true, + limit: 10, +}; -const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'local', - sort: '+follower', -} }; +const popularUsers: Paging = { + endpoint: 'users', + limit: 10, + noPaging: true, + params: { + state: 'alive', + origin: 'local', + sort: '+follower', + }, +}; </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts index 0f81c0817d..2a7947c6f8 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index 664c4da203..6d2f0bbb99 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,14 +41,14 @@ import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const isLocked = ref(false); const hideOnlineStatus = ref(false); const noCrawle = ref(false); watch([isLocked, hideOnlineStatus, noCrawle], () => { - os.api('i/update', { + misskeyApi('i/update', { isLocked: !!isLocked.value, hideOnlineStatus: !!hideOnlineStatus.value, noCrawle: !!noCrawle.value, diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts index d2c6f7d479..c6088a5ae3 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 37aa677b44..3194641cdb 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,9 @@ import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; import { chooseFileFromPc } from '@/scripts/select-file.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const name = ref($i.name ?? ''); const description = ref($i.description ?? ''); @@ -68,7 +70,7 @@ function setAvatar(ev) { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('cropImageAsk'), + text: i18n.ts.cropImageAsk, okText: i18n.ts.cropYes, cancelText: i18n.ts.cropNo, }); diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts index 31176c0832..f0206e0cb4 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index 621995cc5b..a4b9746f4b 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -29,7 +29,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; @@ -39,7 +39,7 @@ const isFollowing = ref(false); async function follow() { isFollowing.value = true; - os.api('following/create', { + misskeyApi('following/create', { userId: props.user.id, }); } @@ -59,8 +59,8 @@ async function follow() { top: 30px; left: 13px; z-index: 2; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); border: solid 4px var(--panel); } diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts index 5182db12b2..3f5ae734bd 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; import { userDetailed } from '../../.storybook/fakes.js'; import MkUserSetupDialog from './MkUserSetupDialog.vue'; @@ -38,17 +38,17 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), - rest.post('/api/pinned-users', (req, res, ctx) => { - return res(ctx.json([ + http.post('/api/pinned-users', () => { + return HttpResponse.json([ userDetailed('44'), userDetailed('49'), - ])); + ]); }), ], }, diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index be945c1066..bd8949890c 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps" style="text-align: center;"> <i class="ph-bell-ringing ph-bold ph-lg" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div> - <div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div> + <div style="padding: 0 16px;">{{ i18n.tsx._initialAccountSetting.pushNotificationDescription({ name: instance.name ?? host }) }}</div> <MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/> <div class="_buttonsCenter" style="margin-top: 16px;"> <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ph-arrow-left ph-bold ph-lg"></i> {{ i18n.ts.goBack }}</MkButton> @@ -110,7 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps" style="text-align: center;"> <i class="ph-check ph-bold ph-lg" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div> - <div>{{ i18n.t('_initialAccountSetting.youCanContinueTutorial', { name: instance.name ?? host }) }}</div> + <div>{{ i18n.tsx._initialAccountSetting.youCanContinueTutorial({ name: instance.name ?? host }) }}</div> <div class="_buttonsCenter" style="margin-top: 16px;"> <MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ph-arrow-right ph-bold ph-lg"></i></MkButton> </div> diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue index 37548952b6..054a503257 100644 --- a/packages/frontend/src/components/MkUsersTooltip.vue +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 61edc345a9..bd6edad663 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -1,29 +1,29 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} </div> - <button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> + <button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> <div :class="$style.icon"><i class="ph-globe-hemisphere-west ph-bold ph-lg"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span> </div> </button> - <button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')"> + <button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')"> <div :class="$style.icon"><i class="ph-house ph-bold ph-lg"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span> </div> </button> - <button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')"> + <button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')"> <div :class="$style.icon"><i class="ph-lock ph-bold ph-lg"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span> @@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{ isSilenced: boolean; localOnly: boolean; src?: HTMLElement; + isReplyVisibilitySpecified?: boolean; }>(), { }); diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index 746ed3e0de..cab42cd59d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, shallowRef, ref, nextTick } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -25,9 +25,9 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement | null>(null); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 30; const fetching = ref(true); @@ -53,7 +53,11 @@ async function renderChart() { })); }; - const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); + + fetching.value = false; + + await nextTick(); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; @@ -65,6 +69,8 @@ async function renderChart() { const max = Math.max(...raw.read); + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'bar', data: { @@ -97,7 +103,6 @@ async function renderChart() { type: 'time', offset: true, time: { - stepSize: 1, unit: 'day', displayFormats: { day: 'M/d', @@ -108,6 +113,7 @@ async function renderChart() { display: false, }, ticks: { + stepSize: 1, display: true, maxRotation: 0, autoSkipPadding: 8, @@ -141,13 +147,10 @@ async function renderChart() { }, external: externalTooltipHandler, }, - gradient, }, }, plugins: [chartVLine(vLineColor)], }); - - fetching.value = false; } onMounted(async () => { diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 862a38bd54..d8e6ba9a09 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div v-if="meta" :class="$style.root"> <div :class="[$style.main, $style.panel]"> - <img :src="instance.iconUrl || instance.faviconUrl || '/apple-touch-icon.png'" alt="" :class="$style.mainIcon"/> + <img :src="instance.iconUrl || '/apple-touch-icon.png'" alt="" :class="$style.mainIcon"/> <button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ph-dots-three ph-bold ph-lg"></i></button> <div :class="$style.mainFg"> <h1 :class="$style.mainTitle"> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </h1> <div :class="$style.mainAbout"> <!-- eslint-disable-next-line vue/no-v-html --> - <div v-html="meta.description || i18n.ts.headlineMisskey"></div> + <div v-html="sanitizeHtml(meta.description) || i18n.ts.headlineMisskey"></div> </div> <div v-if="instance.disableRegistration" :class="$style.mainWarn"> <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> @@ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import sanitizeHtml from 'sanitize-html'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; @@ -63,6 +64,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; @@ -71,11 +73,11 @@ import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart. const meta = ref<Misskey.entities.MetaResponse | null>(null); const stats = ref<Misskey.entities.StatsResponse | null>(null); -os.api('meta', { detail: true }).then(_meta => { +misskeyApi('meta', { detail: true }).then(_meta => { meta.value = _meta; }); -os.api('stats', {}).then((res) => { +misskeyApi('stats', {}).then((res) => { stats.value = res; }); @@ -108,21 +110,27 @@ function showMenu(ev) { text: i18n.ts.impressum, icon: 'ph-newspaper-clipping ph-bold ph-lg', action: () => { - window.open(instance.impressumUrl, '_blank', 'noopener'); + window.open(instance.impressumUrl!, '_blank', 'noopener'); }, } : undefined, (instance.tosUrl) ? { text: i18n.ts.termsOfService, icon: 'ph-notebook ph-bold ph-lg', action: () => { - window.open(instance.tosUrl, '_blank', 'noopener'); + window.open(instance.tosUrl!, '_blank', 'noopener'); }, } : undefined, (instance.privacyPolicyUrl) ? { text: i18n.ts.privacyPolicy, icon: 'ph-shield ph-bold ph-lg', action: () => { - window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); + window.open(instance.privacyPolicyUrl!, '_blank', 'noopener'); }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { + } : undefined, (instance.donationUrl) ? { + text: i18n.ts.donation, + icon: 'ph-hand-coins ph-bold ph-lg', + action: () => { + window.open(instance.donationUrl, '_blank', 'noopener'); + }, + } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, icon: 'ph-question ph-bold ph-lg', action: () => { diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 28943efd1a..ad2105cc0b 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -32,7 +32,7 @@ const emit = defineEmits<{ function done() { emit('done'); - modal.value.close(); + modal.value?.close(); } watch(() => props.showing, () => { diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index bc1f33c43e..05a0f6e04e 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <header :class="$style.editHeader"> <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> - <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> + <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option> </MkSelect> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> @@ -104,19 +104,21 @@ const updateWidget = (id, data) => { }; function onContextmenu(widget: Widget, ev: MouseEvent) { - const isLink = (el: HTMLElement) => { + const element = ev.target as HTMLElement | null; + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; + if (element && isLink(element)) return; + if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes['contenteditable'])) return; if (window.getSelection()?.toString() !== '') return; os.contextMenu([{ type: 'label', - text: i18n.t(`_widgets.${widget.name}`), + text: i18n.ts._widgets[widget.name], }, { icon: 'ph-gear ph-bold ph-lg', text: i18n.ts.settings, diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index e5b8bd9b15..f13b53b005 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -63,7 +63,7 @@ import { defaultStore } from '@/store.js'; const minHeight = 50; const minWidth = 250; -function dragListen(fn: (ev: MouseEvent) => void) { +function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) { window.addEventListener('mousemove', fn); window.addEventListener('touchmove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); @@ -138,11 +138,12 @@ function onContextmenu(ev: MouseEvent) { // 最前面へ移動 function top() { if (rootEl.value) { - rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low'); + rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low').toString(); } } function maximize() { + if (rootEl.value == null) return; maximized.value = true; unResizedTop = rootEl.value.style.top; unResizedLeft = rootEl.value.style.left; @@ -155,6 +156,7 @@ function maximize() { } function unMaximize() { + if (rootEl.value == null) return; maximized.value = false; rootEl.value.style.top = unResizedTop; rootEl.value.style.left = unResizedLeft; @@ -163,6 +165,7 @@ function unMaximize() { } function minimize() { + if (rootEl.value == null) return; minimized.value = true; unResizedWidth = rootEl.value.style.width; unResizedHeight = rootEl.value.style.height; @@ -171,8 +174,8 @@ function minimize() { } function unMinimize() { + if (rootEl.value == null) return; const main = rootEl.value; - if (main == null) return; minimized.value = false; rootEl.value.style.width = unResizedWidth; @@ -199,9 +202,17 @@ function onDblClick() { } } -function onHeaderMousedown(evt: MouseEvent) { +function getPositionX(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0; +} + +function getPositionY(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0; +} + +function onHeaderMousedown(evt: MouseEvent | TouchEvent) { // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 - if (evt.button === 2) return; + if ('button' in evt && evt.button === 2) return; let beforeMaximized = false; @@ -226,8 +237,8 @@ function onHeaderMousedown(evt: MouseEvent) { const position = main.getBoundingClientRect(); - const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX; - const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY; + const clickX = getPositionX(evt); + const clickY = getPositionY(evt); const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる const moveBaseY = beforeMaximized ? 20 : clickY - position.top; const browserWidth = window.innerWidth; @@ -251,8 +262,10 @@ function onHeaderMousedown(evt: MouseEvent) { // 右はみ出し if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - rootEl.value.style.left = moveLeft + 'px'; - rootEl.value.style.top = moveTop + 'px'; + if (rootEl.value) { + rootEl.value.style.left = moveLeft + 'px'; + rootEl.value.style.top = moveTop + 'px'; + } } if (beforeMaximized) { @@ -261,26 +274,26 @@ function onHeaderMousedown(evt: MouseEvent) { // 動かした時 dragListen(me => { - const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; - const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + const x = getPositionX(me); + const y = getPositionY(me); move(x, y); }); } // 上ハンドル掴み時 -function onTopHandleMousedown(evt) { +function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; // どういうわけかnullになることがある if (main == null) return; - const base = evt.clientY; + const base = getPositionY(evt); const height = parseInt(getComputedStyle(main, '').height, 10); const top = parseInt(getComputedStyle(main, '').top, 10); // 動かした時 dragListen(me => { - const move = me.clientY - base; + const move = getPositionY(me) - base; if (top + move > 0) { if (height + -move > minHeight) { applyTransformHeight(height + -move); @@ -297,18 +310,18 @@ function onTopHandleMousedown(evt) { } // 右ハンドル掴み時 -function onRightHandleMousedown(evt) { +function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientX; + const base = getPositionX(evt); const width = parseInt(getComputedStyle(main, '').width, 10); const left = parseInt(getComputedStyle(main, '').left, 10); const browserWidth = window.innerWidth; // 動かした時 dragListen(me => { - const move = me.clientX - base; + const move = getPositionX(me) - base; if (left + width + move < browserWidth) { if (width + move > minWidth) { applyTransformWidth(width + move); @@ -322,18 +335,18 @@ function onRightHandleMousedown(evt) { } // 下ハンドル掴み時 -function onBottomHandleMousedown(evt) { +function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientY; + const base = getPositionY(evt); const height = parseInt(getComputedStyle(main, '').height, 10); const top = parseInt(getComputedStyle(main, '').top, 10); const browserHeight = window.innerHeight; // 動かした時 dragListen(me => { - const move = me.clientY - base; + const move = getPositionY(me) - base; if (top + height + move < browserHeight) { if (height + move > minHeight) { applyTransformHeight(height + move); @@ -347,17 +360,17 @@ function onBottomHandleMousedown(evt) { } // 左ハンドル掴み時 -function onLeftHandleMousedown(evt) { +function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - const base = evt.clientX; + const base = getPositionX(evt); const width = parseInt(getComputedStyle(main, '').width, 10); const left = parseInt(getComputedStyle(main, '').left, 10); // 動かした時 dragListen(me => { - const move = me.clientX - base; + const move = getPositionX(me) - base; if (left + move > 0) { if (width + -move > minWidth) { applyTransformWidth(width + -move); @@ -374,25 +387,25 @@ function onLeftHandleMousedown(evt) { } // 左上ハンドル掴み時 -function onTopLeftHandleMousedown(evt) { +function onTopLeftHandleMousedown(evt: MouseEvent | TouchEvent) { onTopHandleMousedown(evt); onLeftHandleMousedown(evt); } // 右上ハンドル掴み時 -function onTopRightHandleMousedown(evt) { +function onTopRightHandleMousedown(evt: MouseEvent | TouchEvent) { onTopHandleMousedown(evt); onRightHandleMousedown(evt); } // 右下ハンドル掴み時 -function onBottomRightHandleMousedown(evt) { +function onBottomRightHandleMousedown(evt: MouseEvent | TouchEvent) { onBottomHandleMousedown(evt); onRightHandleMousedown(evt); } // 左下ハンドル掴み時 -function onBottomLeftHandleMousedown(evt) { +function onBottomLeftHandleMousedown(evt: MouseEvent | TouchEvent) { onBottomHandleMousedown(evt); onLeftHandleMousedown(evt); } @@ -400,23 +413,23 @@ function onBottomLeftHandleMousedown(evt) { // 高さを適用 function applyTransformHeight(height) { if (height > window.innerHeight) height = window.innerHeight; - rootEl.value.style.height = height + 'px'; + if (rootEl.value) rootEl.value.style.height = height + 'px'; } // 幅を適用 function applyTransformWidth(width) { if (width > window.innerWidth) width = window.innerWidth; - rootEl.value.style.width = width + 'px'; + if (rootEl.value) rootEl.value.style.width = width + 'px'; } // Y座標を適用 function applyTransformTop(top) { - rootEl.value.style.top = top + 'px'; + if (rootEl.value) rootEl.value.style.top = top + 'px'; } // X座標を適用 function applyTransformLeft(left) { - rootEl.value.style.left = left + 'px'; + if (rootEl.value) rootEl.value.style.left = left + 'px'; } function onBrowserResize() { @@ -438,8 +451,10 @@ onMounted(() => { applyTransformWidth(props.initialWidth); if (props.initialHeight) applyTransformHeight(props.initialHeight); - applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); - applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); + if (rootEl.value) { + applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); + applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); + } // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする top(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index a9b2e8a00d..3ad2a95bc3 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,7 @@ if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid const fetching = ref(true); const title = ref<string | null>(null); const player = ref({ - url: null, + url: null as string | null, width: null, height: null, }); diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue index 2bf6361ac8..f85944cd04 100644 --- a/packages/frontend/src/components/SkApprovalUser.vue +++ b/packages/frontend/src/components/SkApprovalUser.vue @@ -33,6 +33,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.User; @@ -42,7 +43,7 @@ let reason = ref(''); let email = ref(''); function getReason() { - return os.api('admin/show-user', { + return misskeyApi('admin/show-user', { userId: props.user.id, }).then(info => { reason.value = info?.signupReason; @@ -87,7 +88,7 @@ async function approveAccount() { text: i18n.ts.approveConfirm, }); if (confirm.canceled) return; - await os.api('admin/approve-user', { userId: props.user.id }); + await misskeyApi('admin/approve-user', { userId: props.user.id }); emits('deleted', props.user.id); } </script> diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue index d69e5fecec..9cfc332698 100644 --- a/packages/frontend/src/components/SkInstanceTicker.vue +++ b/packages/frontend/src/components/SkInstanceTicker.vue @@ -46,11 +46,22 @@ const bg = { align-items: center; height: 1.5ex; border-radius: var(--radius-xl); - margin-top: 5px; padding: 4px; overflow: clip; color: #fff; - text-shadow: -1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000; + text-shadow: /* .866 ≈ sin(60deg) */ + 1px 0 1px #000, + .866px .5px 1px #000, + .5px .866px 1px #000, + 0 1px 1px #000, + -.5px .866px 1px #000, + -.866px .5px 1px #000, + -1px 0 1px #000, + -.866px -.5px 1px #000, + -.5px -.866px 1px #000, + 0 -1px 1px #000, + .5px -.866px 1px #000, + .866px -.5px 1px #000; } .icon { @@ -59,7 +70,9 @@ const bg = { } .name { - margin-left: 4px; + padding: 0.5ex; + margin: -0.5ex; + margin-left: calc(4px - 0.5ex); line-height: 1; font-size: 0.8em; font-weight: bold; diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 83909654c7..09decad1a2 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - v-if="!hardMuted && !muted" + v-if="!hardMuted && muted === false" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" :tabindex="!isDeleted ? '-1' : undefined" @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> </div> </div> <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SkNoteHeader :note="appearNote" :mini="true"/> </div> </div> - <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> @@ -76,18 +76,18 @@ SPDX-License-Identifier: AGPL-3.0-only /> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" @click.stop/> + <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> @@ -147,7 +147,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -155,7 +155,14 @@ SPDX-License-Identifier: AGPL-3.0-only </article> </div> <div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> - <I18n :src="i18n.ts.userSaysSomething" tag="small"> + <I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> + <I18n v-else :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkUserName :user="appearNote.user"/> @@ -173,7 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; @@ -190,6 +197,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -208,7 +216,8 @@ import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -228,6 +237,7 @@ const emit = defineEmits<{ const router = useRouter(); +const inTimeline = inject<boolean>('inTimeline', false); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); @@ -246,7 +256,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -255,7 +265,7 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } @@ -263,11 +273,11 @@ const isRenote = ( note.value.renote != null && note.value.text == null && note.value.cw == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -277,50 +287,61 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); -const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text).filter(u => u !== renoteUrl && u !== renoteUri) : null); -const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); -const collapsed = defaultStore.state.expandLongNote && appearNote.value.cw == null ? false : ref(appearNote.value.cw == null && isLong); +const collapsed = ref(defaultStore.state.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords)); -const translation = ref<any>(null); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); -const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); +const renoteCollapsed = ref( + defaultStore.state.collapseRenotes && isRenote && ( + ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 + (appearNote.value.myReaction != null) + ) +); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean { +/* Overload FunctionにLintが対応していないのでコメントアウト +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; +*/ +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' { if (mutedWords == null) return false; - if (checkWordMute(note, $i, mutedWords)) return true; - if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true; - if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true; + if (checkWordMute(noteToCheck, $i, mutedWords)) return true; + if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true; + if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true; + + if (checkOnly) return false; + + if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute'; return false; } const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -332,7 +353,7 @@ if (props.mock) { }, { deep: true }); } else { useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -341,7 +362,7 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -359,7 +380,7 @@ if (!props.mock) { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -378,7 +399,7 @@ if (!props.mock) { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -388,54 +409,15 @@ if (!props.mock) { } } -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -// defaultStore.state.visibilityがstringなためstringも受け付けている -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -449,7 +431,7 @@ function renote(visibility: Visibility | 'local') { } if (!props.mock) { - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { @@ -457,7 +439,7 @@ function renote(visibility: Visibility | 'local') { renoted.value = true; }); } - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -466,18 +448,10 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - if (!props.mock) { - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -499,9 +473,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -521,9 +495,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -551,7 +525,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -559,10 +533,11 @@ function reply(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -579,17 +554,17 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - sound.play('reaction'); + sound.playMisskeySfx('reaction'); if (props.mock) { return; } - os.api('notes/like', { + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); - const el = reactButton.value as HTMLElement | null | undefined; + const el = reactButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -598,15 +573,15 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); if (props.mock) { emit('reaction', reaction); return; } - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -619,8 +594,8 @@ function react(viaKeyboard = false): void { } } -function undoReact(note): void { - const oldReaction = note.myReaction; +function undoReact(targetNote: Misskey.entities.Note): void { + const oldReaction = targetNote.myReaction; if (!oldReaction) return; if (props.mock) { @@ -628,8 +603,8 @@ function undoReact(note): void { return; } - os.api('notes/reactions/delete', { - noteId: note.id, + misskeyApi('notes/reactions/delete', { + noteId: targetNote.id, }); } @@ -637,7 +612,7 @@ function undoRenote(note) : void { if (props.mock) { return; } - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: note.id, }); os.toast(i18n.ts.rmboost); @@ -657,32 +632,34 @@ function onContextmenu(ev: MouseEvent): void { return; } - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 if (el.tagName === 'AUDIO') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { +function showMenu(viaKeyboard = false): void { if (props.mock) { return; } - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -714,7 +691,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -736,7 +713,7 @@ function showRenoteMenu(viaKeyboard = false): void { getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), - $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, + ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, }); @@ -756,23 +733,23 @@ function animatedMFM() { } function focus() { - el.value.focus(); + rootEl.value?.focus(); } function blur() { - el.value.blur(); + rootEl.value?.blur(); } function focusBefore() { - focusPrev(el.value); + focusPrev(rootEl.value ?? null); } function focusAfter() { - focusNext(el.value); + focusNext(rootEl.value ?? null); } function readPromo() { - os.api('promo/read', { + misskeyApi('promo/read', { noteId: appearNote.value.id, }); isDeleted.value = true; @@ -819,19 +796,20 @@ function emitUpdReaction(emoji: string, delta: number) { margin: auto; width: calc(100% - 8px); height: calc(100% - 8px); - border: dashed 1px var(--focus); + border: solid 1px var(--focus); border-radius: var(--radius); box-sizing: border-box; } } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } &:hover > .article > .main > .footer > .footerButton { @@ -882,7 +860,6 @@ function emitUpdReaction(emoji: string, delta: number) { } .replyTo { - opacity: 0.7; padding-bottom: 0; } @@ -890,11 +867,28 @@ function emitUpdReaction(emoji: string, delta: number) { position: relative; display: flex; align-items: center; - padding: 24px 38px 16px; + padding: 24px 32px 0 calc(32px + var(--avatar) + 14px); line-height: 28px; white-space: pre; color: var(--renote); + &::before { + content: ''; + position: absolute; + top: 0; + left: calc(32px + .5 * var(--avatar)); + bottom: -8px; + border-left: var(--thread-width) solid var(--thread); + } + + &:first-child { + padding-left: 32px; + + &::before { + display: none; + } + } + & + .article { padding-top: 8px; } @@ -906,7 +900,7 @@ function emitUpdReaction(emoji: string, delta: number) { .renoteAvatar { flex-shrink: 0; - display: inline-block; + display: none; /* same as Firefish, but keeping the element around in case someone wants to add it back via CSS override */ width: 28px; height: 28px; margin: 0 8px 0 0; @@ -987,8 +981,8 @@ function emitUpdReaction(emoji: string, delta: number) { display: block !important; position: sticky !important; margin: 0 14px 0 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); position: sticky !important; top: calc(22px + var(--stickyTop, 0px)); left: 0; @@ -1130,24 +1124,24 @@ function emitUpdReaction(emoji: string, delta: number) { @container (max-width: 580px) { .root { font-size: 0.95em; + --avatar: 46px; } .renote { - padding: 24px 28px 16px; + padding: 24px 26px 0 calc(26px + var(--avatar) + 14px); + + &::before { + left: calc(26px + .5 * var(--avatar)); + } } .collapsedRenoteTarget { - padding: 8px 28px 24px; + padding: 8px 26px 24px; } .article { padding: 24px 26px; } - - .avatar { - width: 50px; - height: 50px; - } } @container (max-width: 500px) { @@ -1164,9 +1158,23 @@ function emitUpdReaction(emoji: string, delta: number) { } } +@container (max-width: 500px) { + .renote { + padding: 23px 25px 0 calc(25px + var(--avatar) + 14px); + + &::before { + left: calc(25px + .5 * var(--avatar)); + } + } +} + @container (max-width: 480px) { .renote { - padding: 20px 24px 8px; + padding: 22px 24px 0 calc(24px + var(--avatar) + 14px); + + &::before { + left: calc(24px + .5 * var(--avatar)); + } } .tip { @@ -1184,10 +1192,12 @@ function emitUpdReaction(emoji: string, delta: number) { } @container (max-width: 450px) { + .root { + --avatar: 44px; + } + .avatar { margin: 0 10px 0 0; - width: 46px; - height: 46px; top: calc(14px + var(--stickyTop, 0px)); } } @@ -1220,11 +1230,6 @@ function emitUpdReaction(emoji: string, delta: number) { } @container (max-width: 300px) { - .avatar { - width: 44px; - height: 44px; - } - .root:not(.showActionsOnlyHover) { .footerButton { &:not(:last-child) { @@ -1256,5 +1261,6 @@ function emitUpdReaction(emoji: string, delta: number) { .clickToOpen { cursor: pointer; + -webkit-tap-highlight-color: transparent; } </style> diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index f850adba1b..ced7e7a176 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!muted" v-show="!isDeleted" - ref="el" + ref="rootEl" v-hotkey="keymap" :class="$style.root" > @@ -40,10 +40,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <template v-if="appearNote.reply && appearNote.reply.replyId"> - <SkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws"/> + <SkNoteSub v-for="note in conversation" :key="note.id" :note="note" :expandAllCws="props.expandAllCws" detailed/> </template> - <SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws"/> - <article :class="$style.note" @contextmenu.stop="onContextmenu"> + <SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed/> + <article :id="appearNote.id" ref="noteEl" :class="$style.note" tabindex="-1" @contextmenu.stop="onContextmenu"> <header :class="$style.noteHeader"> <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/> <div style="display: flex; align-items: center; white-space: nowrap; overflow: hidden;"> @@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> </div> <SkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> @@ -96,32 +96,32 @@ SPDX-License-Identifier: AGPL-3.0-only <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> - <div v-else> - <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <div v-else-if="translation"> + <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> - <div v-if="appearNote.files.length > 0"> + <div v-if="appearNote.files && appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </div> - <footer :class="$style.footer"> - <div :class="$style.noteFooterInfo"> - <div v-if="appearNote.updatedAt"> - {{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/> - </div> - <MkA :to="notePage(appearNote)"> - <MkTime :time="appearNote.createdAt" mode="detail" colored/> - </MkA> + <div :class="$style.noteFooterInfo"> + <div v-if="appearNote.updatedAt"> + {{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/> </div> - <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <MkA :to="notePage(appearNote)"> + <MkTime :time="appearNote.createdAt" mode="detail" colored/> + </MkA> + </div> + <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <footer :class="$style.footer"> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p> @@ -162,7 +162,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> <i class="ph-paperclip ph-bold ph-lg"></i> </button> - <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()"> <i class="ph-dots-three ph-bold ph-lg"></i> </button> </footer> @@ -174,11 +174,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button> </div> <div> - <div v-if="tab === 'replies'" :class="$style.tab_replies"> + <div v-if="tab === 'replies'"> <div v-if="!repliesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> </div> - <SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" /> + <SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="true"/> </div> <div v-else-if="tab === 'renotes'" :class="$style.tab_renotes"> <MkPagination :pagination="renotesPagination" :disableAutoLoad="true"> @@ -191,11 +191,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkPagination> </div> - <div v-if="tab === 'quotes'" :class="$style.tab_replies"> + <div v-if="tab === 'quotes'"> <div v-if="!quotesLoaded" style="padding: 16px"> <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> </div> - <SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws"/> + <SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws" :reply="true"/> </div> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div :class="$style.reactionTabs"> @@ -228,8 +228,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, shallowRef, watch } from 'vue'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; @@ -245,6 +245,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -261,9 +262,10 @@ import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -280,7 +282,7 @@ if (noteViewInterruptors.length > 0) { let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { - result = await interruptor.handler(result); + result = await interruptor.handler(result!) as Misskey.entities.Note | null; if (result === null) { isDeleted.value = true; return; @@ -289,18 +291,19 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note.value = result; + note.value = result as Misskey.entities.Note; }); } const isRenote = ( note.value.renote != null && note.value.text == null && - note.value.fileIds.length === 0 && + note.value.fileIds && note.value.fileIds.length === 0 && note.value.poll == null ); -const el = shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); +const noteEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const menuVersionsButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>(); @@ -310,24 +313,22 @@ const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); -const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; -const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); -const translation = ref(null); +const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; -const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); watch(() => props.expandAllCws, (expandAllCws) => { @@ -335,7 +336,7 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -347,23 +348,23 @@ if ($i) { const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renoteButton.value.renote(true), + 'q': () => renote(appearNote.value.visibility), 'esc': blur, - 'm|o': () => menu(true), + 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, }; provide('react', (reaction: string) => { - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); }); const tab = ref('replies'); -const reactionTabType = ref(null); +const reactionTabType = ref<string | null>(null); -const renotesPagination = computed(() => ({ +const renotesPagination = computed<Paging>(() => ({ endpoint: 'notes/renotes', limit: 10, params: { @@ -371,7 +372,7 @@ const renotesPagination = computed(() => ({ }, })); -const reactionsPagination = computed(() => ({ +const reactionsPagination = computed<Paging>(() => ({ endpoint: 'notes/reactions', limit: 10, params: { @@ -381,20 +382,20 @@ const reactionsPagination = computed(() => ({ })); async function addReplyTo(replyNote: Misskey.entities.Note) { - replies.value.unshift(replyNote); - appearNote.value.repliesCount += 1; + replies.value.unshift(replyNote); + appearNote.value.repliesCount += 1; } async function removeReply(id: Misskey.entities.Note['id']) { - const replyIdx = replies.value.findIndex(note => note.id === id); - if (replyIdx >= 0) { - replies.value.splice(replyIdx, 1); - appearNote.value.repliesCount -= 1; - } + const replyIdx = replies.value.findIndex(note => note.id === id); + if (replyIdx >= 0) { + replies.value.splice(replyIdx, 1); + appearNote.value.repliesCount -= 1; + } } useNoteCapture({ - rootEl: el, + rootEl: rootEl, note: appearNote, pureNote: note, isDeletedRef: isDeleted, @@ -402,7 +403,7 @@ useNoteCapture({ }); useTooltip(renoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, }); @@ -420,7 +421,7 @@ useTooltip(renoteButton, async (showing) => { }); useTooltip(quoteButton, async (showing) => { - const renotes = await os.api('notes/renotes', { + const renotes = await misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 11, quote: true, @@ -438,53 +439,15 @@ useTooltip(quoteButton, async (showing) => { }, {}, 'closed'); }); -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: Visibility | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -497,14 +460,14 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.value.id, channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); - } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -513,17 +476,9 @@ function renote(visibility: Visibility | 'local') { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.value.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); - } - - os.api('notes/create', { - localOnly: visibility === 'local' ? true : localOnlySetting, - visibility: noteVisibility, + misskeyApi('notes/create', { + localOnly: localOnly, + visibility: visibility, renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); @@ -541,9 +496,9 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -563,9 +518,9 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, - userId: $i.id, + userId: $i?.id, limit: 1, quote: true, }).then((res) => { @@ -591,7 +546,7 @@ function reply(viaKeyboard = false): void { reply: appearNote.value, channel: appearNote.value.channel, animation: !viaKeyboard, - }, () => { + }).then(() => { focus(); }); } @@ -600,7 +555,9 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -613,10 +570,10 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - sound.play('reaction'); + reactionPicker.show(reactButton.value ?? null, note.value, reaction => { + sound.playMisskeySfx('reaction'); - os.api('notes/reactions/create', { + misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, }); @@ -632,7 +589,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: appearNote.value.id, override: defaultLike.value, }); @@ -648,14 +606,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -671,26 +629,28 @@ function undoRenote() : void { } function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement) => { + const isLink = (el: HTMLElement): boolean => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } + return false; }; - if (isLink(ev.target)) return; - if (window.getSelection().toString() !== '') return; + + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; if (defaultStore.state.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } -function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); +function showMenu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -715,7 +675,7 @@ function showRenoteMenu(viaKeyboard = false): void { icon: 'ph-trash ph-bold ph-lg', danger: true, action: () => { - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: note.value.id, }); isDeleted.value = true; @@ -726,18 +686,18 @@ function showRenoteMenu(viaKeyboard = false): void { } function focus() { - el.value.focus(); + noteEl.value?.focus(); } function blur() { - el.value.blur(); + noteEl.value?.blur(); } const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; - os.api('notes/children', { + misskeyApi('notes/children', { noteId: appearNote.value.id, limit: 30, showQuotes: false, @@ -752,7 +712,7 @@ const quotesLoaded = ref(false); function loadQuotes() { quotesLoaded.value = true; - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, limit: 30, quote: true, @@ -767,10 +727,12 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; - os.api('notes/conversation', { + if (appearNote.value.replyId == null) return; + misskeyApi('notes/conversation', { noteId: appearNote.value.replyId, }).then(res => { conversation.value = res.reverse(); + focus(); }); } @@ -787,6 +749,31 @@ function animatedMFM() { }).then((res) => { if (!res.canceled) allowAnim.value = true; }); } } + +let isScrolling = false; + +function setScrolling() { + isScrolling = true; +} + +onMounted(() => { + document.addEventListener('wheel', setScrolling); + isScrolling = false; + noteEl.value?.scrollIntoView({ block: 'center' }); +}); + +onUpdated(() => { + if (!isScrolling) { + noteEl.value?.scrollIntoView({ block: 'center' }); + if (location.hash) { + location.replace(location.hash); // Jump to highlighted reply + } + } +}); + +onUnmounted(() => { + document.removeEventListener('wheel', setScrolling); +}); </script> <style lang="scss" module> @@ -798,23 +785,19 @@ function animatedMFM() { } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } .replyTo { - opacity: 0.7; padding-bottom: 0; } -.replyToMore { - opacity: 0.7; -} - .renote { display: flex; align-items: center; @@ -859,6 +842,7 @@ function animatedMFM() { } .note { + position: relative; padding: 32px; font-size: 1.2em; overflow: hidden; @@ -866,6 +850,28 @@ function animatedMFM() { &:hover > .main > .footer > .button { opacity: 1; } + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: solid 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } } .noteHeader { @@ -879,8 +885,8 @@ function animatedMFM() { .noteHeaderAvatar { display: block; flex-shrink: 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .noteHeaderBody { @@ -1021,10 +1027,17 @@ function animatedMFM() { } .tab { + display: flex; + align-items: center; + justify-content: center; flex: 1; padding: 12px 8px; border-top: solid 2px transparent; border-bottom: solid 2px transparent; + + > i { + margin-right: 8px; + } } .tabActive { diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue index d3ecdf17bb..7dc4c8f019 100644 --- a/packages/frontend/src/components/SkNoteHeader.vue +++ b/packages/frontend/src/components/SkNoteHeader.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> <div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> </div> </div> <div :class="$style.username"><MkAcct :user="note.user"/></div> @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> </div> @@ -82,7 +82,7 @@ import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; import { popupMenu } from '@/os.js'; import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; import { deviceKind } from '@/scripts/device-kind.js'; const props = defineProps<{ @@ -116,6 +116,8 @@ const mock = inject<boolean>('mock', false); .root { display: flex; cursor: auto; /* not clickToOpen-able */ + min-height: 100%; + align-items: center; } .classicRoot { @@ -135,6 +137,7 @@ const mock = inject<boolean>('mock', false); display: flex; align-items: flex-end; margin-left: auto; + margin-bottom: auto; padding-left: 10px; overflow: clip; } @@ -143,10 +146,9 @@ const mock = inject<boolean>('mock', false); .name { flex-shrink: 1; display: block; - // note, these margin top values were done by hand may need futher checking if it actualy aligns pixel perfect - margin: 3px .5em 0 0; + margin: 0 .5em 0 0; padding: 0; - overflow: scroll; + overflow: hidden; overflow-wrap: anywhere; font-size: 1em; font-weight: bold; @@ -192,8 +194,7 @@ const mock = inject<boolean>('mock', false); .username { flex-shrink: 9999999; - // note these top margins were made to align with the instance ticker - margin: 4px .5em 0 0; + margin: 0 .5em 0 0; overflow: hidden; text-overflow: ellipsis; font-size: .95em; diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue index fe12baedeb..533aa60961 100644 --- a/packages/frontend/src/components/SkNoteSimple.vue +++ b/packages/frontend/src/components/SkNoteSimple.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> + <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> </div> </div> </div> @@ -48,6 +48,11 @@ watch(() => props.expandAllCws, (expandAllCws) => { margin: 0; padding: 0; font-size: 0.95em; + + &:hover, &:focus-within { + background: var(--panelHighlight); + transition: background .2s; + } } .avatar { diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index bc482294b4..1cffd8dd66 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-show="!isDeleted" v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]"> +<div v-show="!isDeleted" v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1, [$style.isReply]: props.isReply, [$style.detailed]: props.detailed }]"> <div v-if="!hideLine" :class="$style.line"></div> <div :class="$style.main"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> @@ -24,11 +24,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> <div v-show="note.cw == null || showContent"> - <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> + <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> + <MkReactionsViewer ref="reactionsViewer" :note="note"/> <footer :class="$style.footer"> - <MkReactionsViewer ref="reactionsViewer" :note="note"/> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p> @@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <template v-if="depth < numberOfReplies"> - <SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply"/> + <SkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="[$style.reply, { [$style.single]: replies.length === 1 }]" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws" :onDeleteCallback="removeReply" :isReply="props.isReply"/> </template> <div v-else :class="$style.more"> <MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA> @@ -99,6 +99,8 @@ import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { userPage } from '@/filters/user.js'; @@ -111,6 +113,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; +import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); const hideLine = computed(() => { return props.detail ? true : false; }); @@ -123,8 +126,13 @@ const props = withDefaults(defineProps<{ // how many notes are in between this one and the note being viewed in detail depth?: number; + + isReply?: boolean; + detailed?: boolean; }>(), { depth: 1, + isReply: false, + detailed: false, }); const el = shallowRef<HTMLElement>(); @@ -147,7 +155,7 @@ const replies = ref<Misskey.entities.Note[]>([]); const isRenote = ( props.note.renote != null && props.note.text == null && - props.note.fileIds.length === 0 && + props.note.fileIds && props.note.fileIds.length === 0 && props.note.poll == null ); @@ -174,7 +182,7 @@ useNoteCapture({ }); if ($i) { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: appearNote.value.id, userId: $i.id, limit: 1, @@ -202,8 +210,9 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); + sound.playMisskeySfx('reaction'); if (props.note.reactionAcceptance === 'likeOnly') { - os.api('notes/like', { + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -216,8 +225,8 @@ function react(viaKeyboard = false): void { } } else { blur(); - reactionPicker.show(reactButton.value, reaction => { - os.api('notes/reactions/create', { + reactionPicker.show(reactButton.value ?? null, props.note, reaction => { + misskeyApi('notes/reactions/create', { noteId: props.note.id, reaction: reaction, }); @@ -233,7 +242,8 @@ function react(viaKeyboard = false): void { function like(): void { pleaseLogin(); showMovedDialog(); - os.api('notes/like', { + sound.playMisskeySfx('reaction'); + misskeyApi('notes/like', { noteId: props.note.id, override: defaultLike.value, }); @@ -249,14 +259,14 @@ function like(): void { function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; - os.api('notes/reactions/delete', { + misskeyApi('notes/reactions/delete', { noteId: note.id, }); } function undoRenote() : void { if (!renoted.value) return; - os.api('notes/unrenote', { + misskeyApi('notes/unrenote', { noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); @@ -278,42 +288,14 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); function boostVisibility() { - os.popupMenu([ - { - type: 'button', - icon: 'ph-globe-hemisphere-west ph-bold ph-lg', - text: i18n.ts._visibility['public'], - action: () => { - renote('public'); - }, - }, - { - type: 'button', - icon: 'ph-house ph-bold ph-lg', - text: i18n.ts._visibility['home'], - action: () => { - renote('home'); - }, - }, - { - type: 'button', - icon: 'ph-lock ph-bold ph-lg', - text: i18n.ts._visibility['followers'], - action: () => { - renote('followers'); - }, - }, - { - type: 'button', - icon: 'ph-planet ph-bold ph-lg', - text: i18n.ts._timelines.local, - action: () => { - renote('local'); - }, - }], renoteButton.value); + if (!defaultStore.state.showVisibilitySelectorOnBoost) { + renote(defaultStore.state.visibilityOnBoost); + } else { + os.popupMenu(boostMenuItems(appearNote, renote), renoteButton.value); + } } -function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'local') { +function renote(visibility: Visibility, localOnly: boolean = false) { pleaseLogin(); showMovedDialog(); @@ -326,9 +308,9 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - channelId: props.note.channelId, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -342,10 +324,10 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc os.popup(MkRippleEffect, { x, y }, {}, 'end'); } - os.api('notes/create', { - renoteId: props.note.id, - localOnly: visibility === 'local' ? true : false, - visibility: visibility === 'local' || visibility === 'specified' ? props.note.visibility : visibility, + misskeyApi('notes/create', { + renoteId: appearNote.value.id, + localOnly: localOnly, + visibility: visibility, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -362,7 +344,7 @@ function quote() { renote: appearNote.value, channel: appearNote.value.channel, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -384,7 +366,7 @@ function quote() { os.post({ renote: appearNote.value, }).then(() => { - os.api('notes/renotes', { + misskeyApi('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -413,7 +395,7 @@ function menu(viaKeyboard = false): void { } if (props.detail) { - os.api('notes/children', { + misskeyApi('notes/children', { noteId: props.note.id, limit: numberOfReplies.value, showQuotes: false, @@ -426,35 +408,61 @@ if (props.detail) { <style lang="scss" module> .root { padding: 28px 32px; - font-size: 0.9em; position: relative; + --reply-indent: calc(.5 * var(--avatar)); + &.children { - padding: 10px 0 0 16px; - font-size: 1em; + padding: 10px 0 0 8px; + } + + &.isReply { + /* @link https://utopia.fyi/clamp/calculator?a=450,580,26—36 */ + --avatar: clamp(26px, -8.6154px + 7.6923cqi, 36px); } } .line { position: absolute; - height: calc(100% - 58px); // 58px of avatar height (see SkNote) - left: 60px; + left: calc(32px + .5 * var(--avatar)); // using solid instead of dotted, stylelistic choice - border-left: 2.5px solid rgb(174, 174, 174); - top: 86px; // 28px of .root padding, plus 58px of avatar height (see SkNote) + border-left: var(--thread-width) solid var(--thread); + top: calc(28px + var(--avatar)); // 28px of .root padding, plus 58px of avatar height (see SkNote) + bottom: -28px; } .footer { + display: flex; + align-items: center; + justify-content: space-between; position: relative; z-index: 1; margin-top: 0.4em; - width: max-content; - min-width: min-content; - max-width: fit-content; + max-width: 400px; } .main { - display: flex; + position: relative; + display: flex; + + :is(.detailed, .isReply) &::after { + content: ""; + position: absolute; + top: -12px; + right: -12px; + left: -12px; + bottom: -12px; + background: var(--panelHighlight); + border-radius: var(--radius); + opacity: 0; + transition: opacity .2s, background .2s; + z-index: -1; + } + + :is(.detailed, .isReply) &:hover::after, + :is(.detailed, .isReply) &:focus-within::after { + opacity: 1; + } } .colorBar { @@ -471,8 +479,8 @@ if (props.detail) { flex-shrink: 0; display: block; margin: 0 14px 0 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); border-radius: var(--radius-sm); } @@ -500,10 +508,6 @@ if (props.detail) { padding-top: 10px; opacity: 0.7; - &:not(:last-child) { - margin-right: 1.5em; - } - &:hover { color: var(--fgHighlighted); } @@ -521,15 +525,11 @@ if (props.detail) { @container (max-width: 580px) { .root { padding: 28px 26px 0; + --avatar: 46px; } .line { - left: 50.5px; - } - - .avatar { - width: 50px; - height: 50px; + left: calc(26px + .5 * var(--avatar)); } } @@ -537,6 +537,11 @@ if (props.detail) { .root { padding: 23px 25px; } + + .line { + top: calc(23px + var(--avatar)); + left: calc(25px + .5 * var(--avatar)); + } } @container (max-width: 400px) { @@ -581,21 +586,17 @@ if (props.detail) { @container (max-width: 480px) { .root { padding: 22px 24px; - - &.children { - padding: 10px 0 0 8px; - } } -} -@container (max-width: 450px) { .line { - left: 46px; + top: calc(22px + var(--avatar)); + left: calc(24px + .5 * var(--avatar)); } +} - .avatar { - width: 46px; - height: 46px; +@container (max-width: 450px) { + .root { + --avatar: 44px; } } @@ -616,19 +617,19 @@ if (props.detail) { .threadLine { width: 0; flex-grow: 1; - border-left: 2.5px solid rgb(174, 174, 174); - margin-left: 29px; + border-left: var(--thread-width) solid var(--thread); + margin-left: var(--reply-indent); } .reply { - margin-left: 29px; + margin-left: var(--reply-indent); } .reply:not(:last-child) { - border-left: 2.5px solid rgb(174, 174, 174); + border-left: var(--thread-width) solid var(--thread); &::before { - left: -2px; + left: calc(-1 * var(--thread-width)); } } @@ -637,10 +638,10 @@ if (props.detail) { content: ''; left: 0px; top: -10px; - height: 49px; + height: calc(10px + 10px + .5 * var(--avatar)); width: 15px; - border-left: 2.5px solid rgb(174, 174, 174); - border-bottom: 2.5px solid rgb(174, 174, 174); + border-left: var(--thread-width) solid var(--thread); + border-bottom: var(--thread-width) solid var(--thread); border-bottom-left-radius: 15px; } @@ -649,40 +650,9 @@ if (props.detail) { padding-left: 0 !important; &::before { - left: 29px; + left: var(--reply-indent); width: 0; border-bottom: unset; } } - -@container (max-width: 580px) { - .threadLine, .reply { - margin-left: 25px; - } - .reply::before { - height: 45px; - } - .single::before { - left: 25px; - } - .single { - margin-left: 0; - } -} - -@container (max-width: 450px) { - .threadLine, .reply { - margin-left: 23px; - } - .reply::before { - height: 43px; - } - .single::before { - left: 23px; - width: 9px; - } - .single { - margin-left: 0; - } -} </style> diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index f8de28e346..bed44bbb08 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -77,7 +77,7 @@ <script lang="ts" setup> import { inject, onMounted, ref, shallowRef, computed } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkMediaList from '@/components/MkMediaList.vue'; @@ -177,8 +177,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS .noteHeaderAvatar { display: block; flex-shrink: 0; - width: 58px; - height: 58px; + width: var(--avatar); + height: var(--avatar); } .noteHeaderBody { diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue new file mode 100644 index 0000000000..fbf50067a9 --- /dev/null +++ b/packages/frontend/src/components/SkOneko.vue @@ -0,0 +1,240 @@ +<template> +<div ref="nekoEl" :class="$style.oneko" aria-hidden="true"></div> +</template> + +<script lang="ts" setup> +// oneko.js: https://github.com/adryd325/oneko.js +// modified to be a vue component by ShittyKopper :3 + +import { shallowRef, onMounted } from 'vue'; + +const nekoEl = shallowRef<HTMLDivElement>(); + +let nekoPosX = 32; +let nekoPosY = 32; + +let mousePosX = 0; +let mousePosY = 0; + +let frameCount = 0; +let idleTime = 0; +let idleAnimation: string|null = null; +let idleAnimationFrame = 0; +let lastFrameTimestamp; + +const nekoSpeed = 10; +const spriteSets = { + idle: [[-3, -3]], + alert: [[-7, -3]], + scratchSelf: [ + [-5, 0], + [-6, 0], + [-7, 0], + ], + scratchWallN: [ + [0, 0], + [0, -1], + ], + scratchWallS: [ + [-7, -1], + [-6, -2], + ], + scratchWallE: [ + [-2, -2], + [-2, -3], + ], + scratchWallW: [ + [-4, 0], + [-4, -1], + ], + tired: [[-3, -2]], + sleeping: [ + [-2, 0], + [-2, -1], + ], + N: [ + [-1, -2], + [-1, -3], + ], + NE: [ + [0, -2], + [0, -3], + ], + E: [ + [-3, 0], + [-3, -1], + ], + SE: [ + [-5, -1], + [-5, -2], + ], + S: [ + [-6, -3], + [-7, -2], + ], + SW: [ + [-5, -3], + [-6, -1], + ], + W: [ + [-4, -2], + [-4, -3], + ], + NW: [ + [-1, 0], + [-1, -1], + ], +}; + +function init() { + if (!nekoEl.value) return; + + nekoEl.value.style.left = `${nekoPosX - 16}px`; + nekoEl.value.style.top = `${nekoPosY - 16}px`; + + document.addEventListener('mousemove', (event) => { + mousePosX = event.clientX; + mousePosY = event.clientY; + }); + + window.requestAnimationFrame(onAnimationFrame); +} + +function onAnimationFrame(timestamp) { + // Stops execution if the neko element is removed from DOM + if (!nekoEl.value?.isConnected) { + return; + } + if (!lastFrameTimestamp) { + lastFrameTimestamp = timestamp; + } + if (timestamp - lastFrameTimestamp > 100) { + lastFrameTimestamp = timestamp; + frame(); + } + window.requestAnimationFrame(onAnimationFrame); +} + +// eslint-disable-next-line no-shadow +function setSprite(name, frame) { + if (!nekoEl.value) return; + + const sprite = spriteSets[name][frame % spriteSets[name].length]; + nekoEl.value.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; +} + +function resetIdleAnimation() { + idleAnimation = null; + idleAnimationFrame = 0; +} + +function idle() { + idleTime += 1; + + // every ~ 20 seconds + if ( + idleTime > 10 && + Math.floor(Math.random() * 200) === 0 && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + idleAnimation == null + ) { + let avalibleIdleAnimations = ['sleeping', 'scratchSelf']; + if (nekoPosX < 32) { + avalibleIdleAnimations.push('scratchWallW'); + } + if (nekoPosY < 32) { + avalibleIdleAnimations.push('scratchWallN'); + } + if (nekoPosX > window.innerWidth - 32) { + avalibleIdleAnimations.push('scratchWallE'); + } + if (nekoPosY > window.innerHeight - 32) { + avalibleIdleAnimations.push('scratchWallS'); + } + idleAnimation = + avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)]; + } + + switch (idleAnimation) { + case 'sleeping': + if (idleAnimationFrame < 8) { + setSprite('tired', 0); + break; + } + setSprite('sleeping', Math.floor(idleAnimationFrame / 4)); + if (idleAnimationFrame > 192) { + resetIdleAnimation(); + } + break; + case 'scratchWallN': + case 'scratchWallS': + case 'scratchWallE': + case 'scratchWallW': + case 'scratchSelf': + setSprite(idleAnimation, idleAnimationFrame); + if (idleAnimationFrame > 9) { + resetIdleAnimation(); + } + break; + default: + setSprite('idle', 0); + return; + } + idleAnimationFrame += 1; +} + +function frame() { + if (!nekoEl.value) return; + + frameCount += 1; + const diffX = nekoPosX - mousePosX; + const diffY = nekoPosY - mousePosY; + const distance = Math.sqrt(diffX ** 2 + diffY ** 2); + + if (distance < nekoSpeed || distance < 48) { + idle(); + return; + } + + idleAnimation = null; + idleAnimationFrame = 0; + + if (idleTime > 1) { + setSprite('alert', 0); + // count down after being alerted before moving + idleTime = Math.min(idleTime, 7); + idleTime -= 1; + return; + } + + let direction; + direction = diffY / distance > 0.5 ? 'N' : ''; + direction += diffY / distance < -0.5 ? 'S' : ''; + direction += diffX / distance > 0.5 ? 'W' : ''; + direction += diffX / distance < -0.5 ? 'E' : ''; + setSprite(direction, frameCount); + + nekoPosX -= (diffX / distance) * nekoSpeed; + nekoPosY -= (diffY / distance) * nekoSpeed; + + nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16); + nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16); + + nekoEl.value.style.left = `${nekoPosX - 16}px`; + nekoEl.value.style.top = `${nekoPosY - 16}px`; +} + +onMounted(init); +</script> + +<style module> +.oneko { + width: 32px; + height: 32px; + position: fixed; + pointer-events: none; + image-rendering: pixelated; + z-index: 2147483647; + background-image: url(/client-assets/oneko.gif); +} +</style> diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue index 88602a007c..8c8a343010 100644 --- a/packages/frontend/src/components/form/link.vue +++ b/packages/frontend/src/components/form/link.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue index 6af63d1ec6..ad37daa265 100644 --- a/packages/frontend/src/components/form/section.vue +++ b/packages/frontend/src/components/form/section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue index dc4d197507..f54db0ca82 100644 --- a/packages/frontend/src/components/form/slot.vue +++ b/packages/frontend/src/components/form/slot.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/split.vue b/packages/frontend/src/components/form/split.vue index 8cb24b479e..2a015c9520 100644 --- a/packages/frontend/src/components/form/split.vue +++ b/packages/frontend/src/components/form/split.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 933f00b081..54566dc135 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue new file mode 100644 index 0000000000..162aa2bcf8 --- /dev/null +++ b/packages/frontend/src/components/global/I18n.vue @@ -0,0 +1,46 @@ +<template> +<render/> +</template> + +<script setup lang="ts" generic="T extends string | ParameterizedString"> +import { computed, h } from 'vue'; +import type { ParameterizedString } from '../../../../../locales/index.js'; + +const props = withDefaults(defineProps<{ + src: T; + tag?: string; + // eslint-disable-next-line vue/require-default-prop + textTag?: string; +}>(), { + tag: 'span', +}); + +const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: () => unknown } : NonNullable<unknown>>(); + +const parsed = computed(() => { + let str = props.src as string; + const value: (string | { arg: string; })[] = []; + for (;;) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); + + if (nextBracketOpen === -1) { + value.push(str); + break; + } else { + if (nextBracketOpen > 0) value.push(str.substring(0, nextBracketOpen)); + value.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose), + }); + } + + str = str.substring(nextBracketClose + 1); + } + + return value; +}); + +const render = () => { + return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); +}; +</script> diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index 62f4805a11..c1d8cf0ca6 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, within } from '@storybook/testing-library'; +import { expect, userEvent, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkA from './MkA.vue'; import { tick } from '@/scripts/test-utils.js'; @@ -33,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'); @@ -45,6 +45,7 @@ export const Default = { }, args: { to: '#test', + behavior: 'browser', }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index e2b59869a4..b3c58cf235 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,7 +15,7 @@ import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; const props = withDefaults(defineProps<{ to: string; diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts index 49ec61211c..04960ec60c 100644 --- a/packages/frontend/src/components/global/MkAcct.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 594494f3c8..8cb082585b 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,7 +21,7 @@ import { host as hostRaw } from '@/config.js'; import { defaultStore } from '@/store.js'; defineProps<{ - user: Misskey.entities.UserDetailed; + user: Misskey.entities.User; detail?: boolean; }>(); diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 5ae45ec58f..f6cdc2bf23 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index b3eb6d681f..f13a161ae8 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts index 515d7eab18..933754ec4c 100644 --- a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 4a876931c3..de62fe12a9 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <img v-for="decoration in decorations ?? user.avatarDecorations" :class="[$style.decoration]" - :src="decoration.url" + :src="getDecorationUrl(decoration)" :style="{ rotate: getDecorationAngle(decoration), scale: getDecorationScale(decoration), @@ -81,15 +81,22 @@ const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } : {}); -const url = computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) - ? getStaticImageUrl(props.user.avatarUrl) - : props.user.avatarUrl); +const url = computed(() => { + if (props.user.avatarUrl == null) return null; + if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); + return props.user.avatarUrl; +}); function onClick(ev: MouseEvent): void { if (props.link) return; emit('click', ev); } +function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(decoration.url); + return decoration.url; +} + function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { const angle = decoration.angle ?? 0; return angle === 0 ? undefined : `${angle * 360}deg`; @@ -109,6 +116,7 @@ function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['ava const color = ref<string | undefined>(); watch(() => props.user.avatarBlurhash, () => { + if (props.user.avatarBlurhash == null) return; color.value = extractAvgColorFromBlurhash(props.user.avatarBlurhash); }, { immediate: true, diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts index 7df49a2066..e4e90cddd5 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue index 2ed615f5ff..7c4957d77f 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.vue +++ b/packages/frontend/src/components/global/MkCondensedLine.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts index f50217b70d..9e6177045d 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -48,3 +48,18 @@ export const Missing = { name: Default.args.name, }, } satisfies StoryObj<typeof MkCustomEmoji>; +export const ErrorToText = { + ...Default, + args: { + url: 'https://example.com/404', + name: Default.args.name, + }, +} satisfies StoryObj<typeof MkCustomEmoji>; +export const ErrorToImage = { + ...Default, + args: { + url: 'https://example.com/404', + name: Default.args.name, + fallbackToImage: true, + }, +} satisfies StoryObj<typeof MkCustomEmoji>; diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index e8732d1b16..b57a311c0c 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -1,10 +1,16 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<span v-if="errored">:{{ customEmojiName }}:</span> +<img + v-if="errored && fallbackToImage" + :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" + src="/client-assets/dummy.png" + :title="alt" +/> +<span v-else-if="errored">:{{ customEmojiName }}:</span> <img v-else :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" @@ -24,9 +30,11 @@ import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js' import { defaultStore } from '@/store.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; +import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; const props = defineProps<{ name: string; @@ -37,6 +45,7 @@ const props = defineProps<{ useOriginalSize?: boolean; menu?: boolean; menuReaction?: boolean; + fallbackToImage?: boolean; }>(); const react = inject<((name: string) => void) | null>('react', null); @@ -55,7 +64,7 @@ const rawUrl = computed(() => { }); const url = computed(() => { - if (rawUrl.value == null) return null; + if (rawUrl.value == null) return undefined; const proxied = (rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value)) @@ -91,9 +100,21 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(`:${props.name}:`); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); + }, + }] : []), { + text: i18n.ts.info, + icon: 'ph-info ph-bold ph-lg', + action: async () => { + os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: customEmojiName.value, + }), + }, { + anchor: ev.target, + }); }, - }] : [])], ev.currentTarget ?? ev.target); + }], ev.currentTarget ?? ev.target); } } </script> diff --git a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts index 32deaae8e2..6a8fcf4fe3 100644 --- a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue index 5cc07f7040..4ba6be10fe 100644 --- a/packages/frontend/src/components/global/MkEllipsis.vue +++ b/packages/frontend/src/components/global/MkEllipsis.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts index c8beec7e8f..309c015757 100644 --- a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts +++ b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index b1d62db33c..2e7a0c5bb7 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -1,19 +1,18 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick" v-on:click.stop/> -<span v-else-if="useOsNativeEmojis" :alt="props.emoji" @pointerenter="computeTitle" @click="onClick" v-on:click.stop>{{ props.emoji }}</span> -<span v-else>{{ emoji }}</span> +<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick" v-on:click.stop>{{ colorizedNativeEmoji }}</span> </template> <script lang="ts" setup> import { computed, inject } from 'vue'; -import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js'; +import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js'; import { defaultStore } from '@/store.js'; -import { getEmojiName } from '@/scripts/emojilist.js'; +import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; @@ -27,17 +26,15 @@ const props = defineProps<{ const react = inject<((name: string) => void) | null>('react', null); -const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; +const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.state.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); -const url = computed(() => { - return char2path(props.emoji); -}); +const url = computed(() => char2path(props.emoji)); +const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji)); // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter function computeTitle(event: PointerEvent): void { - const title = getEmojiName(props.emoji as string) ?? props.emoji as string; - (event.target as HTMLElement).title = title; + (event.target as HTMLElement).title = getEmojiName(props.emoji); } function onClick(ev: MouseEvent) { @@ -57,7 +54,7 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(props.emoji); - sound.play('reaction'); + sound.playMisskeySfx('reaction'); }, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index cf0a1dbb5f..daef04cd87 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -1,12 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { expect } from '@storybook/jest'; -import { waitFor } from '@storybook/testing-library'; +import { expect, waitFor } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkError from './MkError.vue'; export const Default = { diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts index a3955c5786..1abbc56f50 100644 --- a/packages/frontend/src/components/global/MkError.stories.meta.ts +++ b/packages/frontend/src/components/global/MkError.stories.meta.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 47b42467d6..2976cd7be8 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue index e78df6b8d9..1a75855fa1 100644 --- a/packages/frontend/src/components/global/MkFooterSpacer.vue +++ b/packages/frontend/src/components/global/MkFooterSpacer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue index 6d7ff4ca49..f35932ae77 100644 --- a/packages/frontend/src/components/global/MkLazy.vue +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkLoading.stories.impl.ts b/packages/frontend/src/components/global/MkLoading.stories.impl.ts index 9cedd68fd8..c781ad0479 100644 --- a/packages/frontend/src/components/global/MkLoading.stories.impl.ts +++ b/packages/frontend/src/components/global/MkLoading.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue index 3f34e83f58..49d8ace37b 100644 --- a/packages/frontend/src/components/global/MkLoading.vue +++ b/packages/frontend/src/components/global/MkLoading.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts index 9cdb490e4b..730351f795 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts @@ -1,12 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { within } from '@storybook/testing-library'; -import { expect } from '@storybook/jest'; +import { expect, within } from '@storybook/test'; import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.js'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index a3bfdf0bb4..f8b5fcfedc 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { VNode, h, defineAsyncComponent, SetupContext } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; @@ -13,12 +13,14 @@ import MkMention from '@/components/MkMention.vue'; import MkEmoji from '@/components/global/MkEmoji.vue'; import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; import MkCode from '@/components/MkCode.vue'; +import MkCodeInline from '@/components/MkCodeInline.vue'; import MkGoogle from '@/components/MkGoogle.vue'; import MkSparkle from '@/components/MkSparkle.vue'; import MkA from '@/components/global/MkA.vue'; import { host } from '@/config.js'; import { defaultStore } from '@/store.js'; import { nyaize as doNyaize } from '@/scripts/nyaize.js'; +import { safeParseFloat } from '@/scripts/safe-parse.js'; const QUOTE_STYLE = ` display: block; @@ -35,7 +37,7 @@ type MfmProps = { nowrap?: boolean; author?: Misskey.entities.UserLite; isNote?: boolean; - emojiUrls?: string[]; + emojiUrls?: Record<string, string>; rootScale?: number; nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; @@ -49,22 +51,28 @@ type MfmEvents = { }; // eslint-disable-next-line import/no-default-export -export default function(props: MfmProps, context: SetupContext<MfmEvents>) { +export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { const isNote = props.isNote ?? true; - const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat ? props.author?.speakAsCat : false : false : false; + const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat ? props.author.speakAsCat : false : false : false; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (props.text == null || props.text === '') return; const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text); - const validTime = (t: string | null | undefined) => { + const validTime = (t: string | boolean | null | undefined) => { if (t == null) return null; + if (typeof t === 'boolean') return null; return t.match(/^[0-9.]+s$/) ? t : null; }; const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : props.isAnim ? true : false; + const validColor = (c: unknown): string | null => { + if (typeof c !== 'string') return null; + return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; + }; + const MkFormula = defineAsyncComponent(() => import('@/components/MkFormula.vue')); /** @@ -115,7 +123,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { case 'tada': { const speed = validTime(token.props.args.speed) ?? '1s'; const delay = validTime(token.props.args.delay) ?? '0s'; - style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both; animation-delay: ${delay};` : ''); + style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : ''); break; } case 'jelly': { @@ -220,14 +228,14 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return h(MkSparkle, {}, genEl(token.children, scale)); } case 'rotate': { - const degrees = parseFloat(token.props.args.deg ?? '90'); + const degrees = safeParseFloat(token.props.args.deg) ?? 90; style = `transform: rotate(${degrees}deg); transform-origin: center center;`; break; } case 'position': { if (!defaultStore.state.advancedMfm) break; - const x = parseFloat(token.props.args.x ?? '0'); - const y = parseFloat(token.props.args.y ?? '0'); + const x = safeParseFloat(token.props.args.x) ?? 0; + const y = safeParseFloat(token.props.args.y) ?? 0; style = `transform: translateX(${x}em) translateY(${y}em);`; break; } @@ -236,24 +244,38 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { style = ''; break; } - const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); - const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); + const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5); + const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5); style = `transform: scale(${x}, ${y});`; scale = scale * Math.max(x, y); break; } case 'fg': { - let color = token.props.args.color; - if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; + let color = validColor(token.props.args.color); + color = color ?? 'f00'; style = `color: #${color}; overflow-wrap: anywhere;`; break; } case 'bg': { - let color = token.props.args.color; - if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00'; + let color = validColor(token.props.args.color); + color = color ?? 'f00'; style = `background-color: #${color}; overflow-wrap: anywhere;`; break; } + case 'border': { + let color = validColor(token.props.args.color); + color = color ? `#${color}` : 'var(--accent)'; + let b_style = token.props.args.style; + if ( + typeof b_style !== 'string' || + !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] + .includes(b_style) + ) b_style = 'solid'; + const width = safeParseFloat(token.props.args.width) ?? 1; + const radius = safeParseFloat(token.props.args.radius) ?? 0; + style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`; + break; + } case 'ruby': { if (token.children.length === 1) { const child = token.children[0]; @@ -292,7 +314,8 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return h('span', { onClick(ev: MouseEvent): void { ev.stopPropagation(); ev.preventDefault(); - context.emit('clickEv', token.props.args.ev ?? ''); + const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; + emit('clickEv', clickEv); } }, genEl(token.children, scale)); } } @@ -353,15 +376,14 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return [h(MkCode, { key: Math.random(), code: token.props.code, - lang: token.props.lang, + lang: token.props.lang ?? undefined, })]; } case 'inlineCode': { - return [h(MkCode, { + return [h(MkCodeInline, { key: Math.random(), code: token.props.code, - inline: true, })]; } @@ -388,6 +410,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { useOriginalSize: scale >= 2.5, menu: props.enableEmojiMenu, menuReaction: props.enableEmojiMenuReaction, + fallbackToImage: false, })]; } else { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -397,8 +420,7 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) { return [h(MkCustomEmoji, { key: Math.random(), name: token.props.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - url: props.emojiUrls ? props.emojiUrls[token.props.name] : null, + url: props.emojiUrls && props.emojiUrls[token.props.name], normal: props.plain, host: props.author.host, useOriginalSize: scale >= 2.5, diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index 05d2872e91..d4327e1463 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { waitFor } from '@storybook/testing-library'; +import { waitFor } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import MkPageHeader from './MkPageHeader.vue'; export const Empty = { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts index 130dde63af..5d2126435e 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 75c8e73582..53bb5472dc 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab = { key: string; + title: string; onClick?: (ev: MouseEvent) => void; } & ( | { @@ -120,8 +121,9 @@ function onTabWheel(ev: WheelEvent) { let entering = false; -async function enter(el: HTMLElement) { +async function enter(element: Element) { entering = true; + const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = '0'; el.style.paddingLeft = '0'; @@ -135,11 +137,12 @@ async function enter(el: HTMLElement) { setTimeout(renderTab, 170); } -function afterEnter(el: HTMLElement) { +function afterEnter(element: Element) { //el.style.width = ''; } -async function leave(el: HTMLElement) { +async function leave(element: Element) { + const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = elementWidth + 'px'; el.style.paddingLeft = ''; @@ -148,7 +151,8 @@ async function leave(el: HTMLElement) { el.style.paddingLeft = '0'; } -function afterLeave(el: HTMLElement) { +function afterLeave(element: Element) { + const el = element as HTMLElement; el.style.width = ''; } diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index a36d9517cd..95ac102013 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,23 +15,23 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> - <template v-if="metadata"> + <template v-if="pageMetadata"> <div v-if="displayBackButton && !narrow" style="margin: 0 -45px 0 0;" :class="$style.buttonsLeft"> <button class="_button" :class="$style.button" style="left: 5px;" @click.stop="goBack()" @touchstart="preventDrag"> <i class="ph-caret-left ph-bold ph-lg"></i> </button> </div> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> - <div v-if="metadata.avatar" :class="$style.titleAvatarContainer"> - <MkAvatar :class="$style.titleAvatar" :user="metadata.avatar" indicator/> + <div v-if="pageMetadata.avatar" :class="$style.titleAvatarContainer"> + <MkAvatar :class="$style.titleAvatar" :user="pageMetadata.avatar" indicator/> </div> - <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i> + <i v-else-if="pageMetadata.icon" :class="[$style.titleIcon, pageMetadata.icon]"></i> <div :class="$style.title"> - <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="false"/> - <div v-else-if="metadata.title">{{ metadata.title }}</div> - <div v-if="metadata.subtitle" :class="$style.subtitle"> - {{ metadata.subtitle }} + <MkUserName v-if="pageMetadata.userName" :user="pageMetadata.userName" :nowrap="true"/> + <div v-else-if="pageMetadata.title">{{ pageMetadata.title }}</div> + <div v-if="pageMetadata.subtitle" :class="$style.subtitle"> + {{ pageMetadata.subtitle }} </div> </div> </div> @@ -55,7 +55,7 @@ import tinycolor from 'tinycolor2'; import XTabs, { Tab } from './MkPageHeader.tabs.vue'; import { scrollToTop } from '@/scripts/scroll.js'; import { globalEvents } from '@/events.js'; -import { injectPageMetadata } from '@/scripts/page-metadata.js'; +import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { PageHeaderItem } from '@/types/page-header.js'; @@ -76,7 +76,7 @@ const emit = defineEmits<{ const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true); -const metadata = injectPageMetadata(); +const pageMetadata = injectReactiveMetadata(); const hideTitle = inject('shouldOmitHeaderTitle', false); const thin_ = props.thin || inject('shouldHeaderThin', false); diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue index a384e06f77..db01c10eb0 100644 --- a/packages/frontend/src/components/global/MkSpacer.vue +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts index 16c62ce03d..186048991e 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts +++ b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 70cc68b14c..89993e1b8e 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -63,27 +63,32 @@ onMounted(() => { watch([parentStickyTop, parentStickyBottom], calc); watch(childStickyTop, () => { + if (bodyEl.value == null) return; bodyEl.value.style.setProperty('--stickyTop', `${childStickyTop.value}px`); }, { immediate: true, }); watch(childStickyBottom, () => { + if (bodyEl.value == null) return; bodyEl.value.style.setProperty('--stickyBottom', `${childStickyBottom.value}px`); }, { immediate: true, }); - headerEl.value.style.position = 'sticky'; - headerEl.value.style.top = 'var(--stickyTop, 0)'; - headerEl.value.style.zIndex = '1000'; - - footerEl.value.style.position = 'sticky'; - footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; - footerEl.value.style.zIndex = '1000'; + if (headerEl.value != null) { + headerEl.value.style.position = 'sticky'; + headerEl.value.style.top = 'var(--stickyTop, 0)'; + headerEl.value.style.zIndex = '1000'; + observer.observe(headerEl.value); + } - observer.observe(headerEl.value); - observer.observe(footerEl.value); + if (footerEl.value != null) { + footerEl.value.style.position = 'sticky'; + footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; + footerEl.value.style.zIndex = '1000'; + observer.observe(footerEl.value); + } }); onUnmounted(() => { diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts index 0eeefa4859..355c839113 100644 --- a/packages/frontend/src/components/global/MkTime.stories.impl.ts +++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts @@ -1,16 +1,16 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; +import { expect } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; 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 = { @@ -123,7 +124,7 @@ export const DetailNow = { export const RelativeOneHourAgo = { ...Empty, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 })); + await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.hoursAgo({ n: 1 })); }, args: { ...Empty.args, @@ -162,7 +163,7 @@ export const DetailOneHourAgo = { export const RelativeOneDayAgo = { ...Empty, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 })); + await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.daysAgo({ n: 1 })); }, args: { ...Empty.args, @@ -201,7 +202,7 @@ export const DetailOneDayAgo = { export const RelativeOneWeekAgo = { ...Empty, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 })); + await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.weeksAgo({ n: 1 })); }, args: { ...Empty.args, @@ -240,7 +241,7 @@ export const DetailOneWeekAgo = { export const RelativeOneMonthAgo = { ...Empty, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 })); + await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.monthsAgo({ n: 1 })); }, args: { ...Empty.args, @@ -279,7 +280,7 @@ export const DetailOneMonthAgo = { export const RelativeOneYearAgo = { ...Empty, async play({ canvasElement }) { - await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 })); + await expect(canvasElement).toHaveTextContent(i18n.tsx._ago.yearsAgo({ n: 1 })); }, args: { ...Empty.args, diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index e11db9dc31..67532268d3 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,7 +24,7 @@ const props = withDefaults(defineProps<{ mode?: 'relative' | 'absolute' | 'detail'; colored?: boolean; }>(), { - origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null, + origin: isChromatic() ? () => new Date('2023-04-01T00:00:00Z') : null, mode: 'relative', }); @@ -55,21 +55,21 @@ const relative = computed<string>(() => { if (invalid) return i18n.ts._ago.invalid; return ( - ago.value >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago.value / 31536000).toString() }) : - ago.value >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago.value / 2592000).toString() }) : - ago.value >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago.value / 604800).toString() }) : - ago.value >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago.value / 86400).toString() }) : - ago.value >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago.value / 3600).toString() }) : - ago.value >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago.value / 60)).toString() }) : - ago.value >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago.value % 60)).toString() }) : + ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) : + ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) : + ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) : + ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) : + ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) : + ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) : + ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) : ago.value >= -3 ? i18n.ts._ago.justNow : - ago.value < -31536000 ? i18n.t('_timeIn.years', { n: Math.round(-ago.value / 31536000).toString() }) : - ago.value < -2592000 ? i18n.t('_timeIn.months', { n: Math.round(-ago.value / 2592000).toString() }) : - ago.value < -604800 ? i18n.t('_timeIn.weeks', { n: Math.round(-ago.value / 604800).toString() }) : - ago.value < -86400 ? i18n.t('_timeIn.days', { n: Math.round(-ago.value / 86400).toString() }) : - ago.value < -3600 ? i18n.t('_timeIn.hours', { n: Math.round(-ago.value / 3600).toString() }) : - ago.value < -60 ? i18n.t('_timeIn.minutes', { n: (~~(-ago.value / 60)).toString() }) : - i18n.t('_timeIn.seconds', { n: (~~(-ago.value % 60)).toString() }) + ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) : + ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) : + ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) : + ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) : + ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) : + ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) : + i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() }) ); }); diff --git a/packages/frontend/src/components/global/MkUrl.stories.impl.ts b/packages/frontend/src/components/global/MkUrl.stories.impl.ts index b35b6114fd..34a4adfe49 100644 --- a/packages/frontend/src/components/global/MkUrl.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUrl.stories.impl.ts @@ -1,13 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../../.storybook/mocks.js'; import MkUrl from './MkUrl.vue'; export const Default = { @@ -59,8 +58,8 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.get('/url', (req, res, ctx) => { - return res(ctx.json({ + http.get('/url', () => { + return HttpResponse.json({ title: 'Misskey Hub', icon: 'https://misskey-hub.net/favicon.ico', description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。', @@ -74,7 +73,7 @@ export const Default = { sitename: 'misskey-hub.net', sensitive: false, url: 'https://misskey-hub.net/', - })); + }); }), ], }, diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 667a113432..b810840b69 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,6 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <component :is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target" @contextmenu.stop="() => {}" + @click.stop > <template v-if="!self"> <span :class="$style.schema">{{ schema }}//</span> diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts index 8f47a6c1ab..88bf4f4e6c 100644 --- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; +import { expect } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes.js'; import MkUserName from './MkUserName.vue'; diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue index be283ea922..c5bcf53102 100644 --- a/packages/frontend/src/components/global/MkUserName.vue +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/global/RouterView.stories.impl.ts b/packages/frontend/src/components/global/RouterView.stories.impl.ts index 2fe4c53e78..5dfe12b0c9 100644 --- a/packages/frontend/src/components/global/RouterView.stories.impl.ts +++ b/packages/frontend/src/components/global/RouterView.stories.impl.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 99ed8adbef..06cb30eff1 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -1,10 +1,13 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<KeepAlive :max="defaultStore.state.numberOfPageCache"> +<KeepAlive + :max="defaultStore.state.numberOfPageCache" + :exclude="pageCacheController" +> <Suspense :timeout="0"> <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> @@ -16,12 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, onBeforeUnmount, provide, shallowRef, ref } from 'vue'; -import { Resolved, Router } from '@/nirax.js'; +import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue'; +import { IRouter, Resolved, RouteDef } from '@/nirax.js'; import { defaultStore } from '@/store.js'; +import { globalEvents } from '@/events.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; const props = defineProps<{ - router?: Router; + router?: IRouter; }>(); const router = props.router ?? inject('router'); @@ -46,20 +51,47 @@ function resolveNested(current: Resolved, d = 0): Resolved | null { } const current = resolveNested(router.current)!; -const currentPageComponent = shallowRef(current.route.component); +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); const currentPageProps = ref(current.props); const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); function onChange({ resolved, key: newKey }) { const current = resolveNested(resolved); - if (current == null) return; + if (current == null || 'redirect' in current.route) return; currentPageComponent.value = current.route.component; currentPageProps.value = current.props; key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props)); + + nextTick(() => { + // ページ遷移完了後に再びキャッシュを有効化 + if (clearCacheRequested.value) { + clearCacheRequested.value = false; + } + }); } router.addListener('change', onChange); +// #region キャッシュ制御 + +/** + * キャッシュクリアが有効になったら、全キャッシュをクリアする + * + * keepAlive側にwatcherがあるのですぐ消えるとはおもうけど、念のためページ遷移完了まではキャッシュを無効化しておく。 + * キャッシュ有効時向けにexcludeを使いたい場合は、pageCacheControllerに並列に突っ込むのではなく、下に追記すること + */ +const pageCacheController = computed(() => clearCacheRequested.value ? /.*/ : undefined); +const clearCacheRequested = ref(false); + +globalEvents.on('requestClearPageCache', () => { + if (_DEV_) console.log('clear page cache requested'); + if (!clearCacheRequested.value) { + clearCacheRequested.value = true; + } +}); + +// #endregion + onBeforeUnmount(() => { router.removeListener('change', onChange); }); diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts deleted file mode 100644 index 2f4d7edabd..0000000000 --- a/packages/frontend/src/components/global/i18n.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { h } from 'vue'; - -export default function(props: { src: string; tag?: string; textTag?: string; }, { slots }) { - let str = props.src; - const parsed = [] as (string | { arg: string; })[]; - while (true) { - const nextBracketOpen = str.indexOf('{'); - const nextBracketClose = str.indexOf('}'); - - if (nextBracketOpen === -1) { - parsed.push(str); - break; - } else { - if (nextBracketOpen > 0) parsed.push(str.substring(0, nextBracketOpen)); - parsed.push({ - arg: str.substring(nextBracketOpen + 1, nextBracketClose), - }); - } - - str = str.substring(nextBracketClose + 1); - } - - return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); -} diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index a3e13c3a50..44d8d59941 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -16,7 +16,7 @@ import MkUserName from './global/MkUserName.vue'; import MkEllipsis from './global/MkEllipsis.vue'; import MkTime from './global/MkTime.vue'; import MkUrl from './global/MkUrl.vue'; -import I18n from './global/i18n.js'; +import I18n from './global/I18n.vue'; import RouterView from './global/RouterView.vue'; import MkLoading from './global/MkLoading.vue'; import MkError from './global/MkError.vue'; diff --git a/packages/frontend/src/components/page/block.type.ts b/packages/frontend/src/components/page/block.type.ts deleted file mode 100644 index cdd39339e6..0000000000 --- a/packages/frontend/src/components/page/block.type.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type BlockBase = { - id: string; - type: string; -}; - -export type TextBlock = BlockBase & { - type: 'text'; - text: string; -}; - -export type SectionBlock = BlockBase & { - type: 'section'; - title: string; - children: Block[]; -}; - -export type ImageBlock = BlockBase & { - type: 'image'; - fileId: string | null; -}; - -export type NoteBlock = BlockBase & { - type: 'note'; - detailed: boolean; - note: string | null; -}; - -export type Block = - TextBlock | SectionBlock | ImageBlock | NoteBlock; diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue index 7dbbaa03b4..164720ac6b 100644 --- a/packages/frontend/src/components/page/page.block.vue +++ b/packages/frontend/src/components/page/page.block.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,6 @@ import XText from './page.text.vue'; import XSection from './page.section.vue'; import XImage from './page.image.vue'; import XNote from './page.note.vue'; -import { Block } from './block.type.js'; function getComponent(type: string) { switch (type) { @@ -27,7 +26,7 @@ function getComponent(type: string) { } defineProps<{ - block: Block, + block: Misskey.entities.PageBlock, h: number, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index 29aebf63e5..ced02943db 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,15 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { ImageBlock } from './block.type.js'; import MediaImage from '@/components/MkMediaImage.vue'; const props = defineProps<{ - block: ImageBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); -const image = ref<Misskey.entities.DriveFile>(props.page.attachedFiles.find(x => x.id === props.block.fileId)); +const image = ref<Misskey.entities.DriveFile | null>(null); + +onMounted(() => { + image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null; +}); + </script> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index d885ebb1d6..7b56494a6e 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,20 +13,20 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { NoteBlock } from './block.type.js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const props = defineProps<{ - block: NoteBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); const note = ref<Misskey.entities.Note | null>(null); onMounted(() => { - os.api('notes/show', { noteId: props.block.note }) + if (props.block.note == null) return; + misskeyApi('notes/show', { noteId: props.block.note }) .then(result => { note.value = result; }); diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue index e4e5a43b59..e3d26d924f 100644 --- a/packages/frontend/src/components/page/page.section.vue +++ b/packages/frontend/src/components/page/page.section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,12 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import { SectionBlock } from './block.type.js'; const XBlock = defineAsyncComponent(() => import('./page.block.vue')); defineProps<{ - block: SectionBlock, + block: Misskey.entities.PageBlock, h: number, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index c0849a6d42..6a9415e137 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -1,26 +1,25 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div class="_gaps"> - <Mfm :text="block.text" :isNote="false"/> + <Mfm :text="block.text ?? ''" :isNote="false"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> </div> </template> <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; -import { TextBlock } from './block.type.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); const props = defineProps<{ - block: TextBlock, + block: Misskey.entities.PageBlock, page: Misskey.entities.Page, }>(); diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index 94ca7bdf04..53c70b01f4 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts index 636e51c374..e3922a0cd5 100644 --- a/packages/frontend/src/config.ts +++ b/packages/frontend/src/config.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -18,7 +18,7 @@ export const langs = _LANGS_; const preParseLocale = miLocalStorage.getItem('locale'); export let locale = preParseLocale ? JSON.parse(preParseLocale) : null; export const version = _VERSION_; -export const instanceName = siteName === 'Sharkey' ? host : siteName; +export const instanceName = siteName === 'Sharkey' || siteName == null ? host : siteName; export const ui = miLocalStorage.getItem('ui'); export const debug = miLocalStorage.getItem('debug') === 'true'; diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index cdd6731269..ad798067b3 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -128,6 +128,7 @@ export const ROLE_POLICIES = [ 'btlAvailable', 'canPublicNote', 'canImportNotes', + 'mentionLimit', 'canInvite', 'inviteLimit', 'inviteLimitCycle', @@ -161,4 +162,28 @@ export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://launcher.moe/error.png'; export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://launcher.moe/missingpage.webp'; export const DEFAULT_INFO_IMAGE_URL = 'https://launcher.moe/nothinghere.png'; -export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; +export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; +export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = { + tada: ['speed=', 'delay='], + jelly: ['speed=', 'delay='], + twitch: ['speed=', 'delay='], + shake: ['speed=', 'delay='], + spin: ['speed=', 'delay=', 'left', 'alternate', 'x', 'y'], + jump: ['speed=', 'delay='], + bounce: ['speed=', 'delay='], + flip: ['h', 'v'], + x2: [], + x3: [], + x4: [], + scale: ['x=', 'y='], + position: ['x=', 'y='], + fg: ['color='], + bg: ['color='], + border: ['width=', 'style=', 'color=', 'radius=', 'noclip'], + font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'], + blur: [], + rainbow: ['speed=', 'delay='], + rotate: ['deg='], + ruby: [], + unixtime: [], +}; diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 6a48159f13..9da3582e1a 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { shallowRef, computed, markRaw, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { api, apiGet } from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { get, set } from '@/scripts/idb-proxy.js'; @@ -52,11 +52,11 @@ export async function fetchCustomEmojis(force = false) { let res; if (force) { - res = await api('emojis', {}); + res = await misskeyApi('emojis', {}); } else { const lastFetchedAt = await get('lastEmojisFetchedAt'); if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return; - res = await apiGet('emojis', {}); + res = await misskeyApiGet('emojis', {}); } customEmojis.value = res.emojis; diff --git a/packages/frontend/src/debug.ts b/packages/frontend/src/debug.ts index 6df65bb763..8bb8012ae3 100644 --- a/packages/frontend/src/debug.ts +++ b/packages/frontend/src/debug.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts index dd9691d9e2..23fd1bddf4 100644 --- a/packages/frontend/src/directives/adaptive-bg.ts +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts index 220cf4b9a6..b436075fcd 100644 --- a/packages/frontend/src/directives/adaptive-border.ts +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/anim.ts b/packages/frontend/src/directives/anim.ts index cf49799ef5..d5b6ae4287 100644 --- a/packages/frontend/src/directives/anim.ts +++ b/packages/frontend/src/directives/anim.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts index 3fcff4d978..706d4a9ee4 100644 --- a/packages/frontend/src/directives/appear.ts +++ b/packages/frontend/src/directives/appear.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts index 2b3cdb27a5..5bb48bbcdd 100644 --- a/packages/frontend/src/directives/click-anime.ts +++ b/packages/frontend/src/directives/click-anime.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts index ae3e31e291..f200f242ed 100644 --- a/packages/frontend/src/directives/follow-append.ts +++ b/packages/frontend/src/directives/follow-append.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/get-size.ts b/packages/frontend/src/directives/get-size.ts index 56ff64035f..2655c76c48 100644 --- a/packages/frontend/src/directives/get-size.ts +++ b/packages/frontend/src/directives/get-size.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts index 13e548299f..b082b6edf2 100644 --- a/packages/frontend/src/directives/hotkey.ts +++ b/packages/frontend/src/directives/hotkey.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts index fcd7c3091e..bda7738ccd 100644 --- a/packages/frontend/src/directives/index.ts +++ b/packages/frontend/src/directives/index.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts index 4916fcbd8d..bbcc220e09 100644 --- a/packages/frontend/src/directives/panel.ts +++ b/packages/frontend/src/directives/panel.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts index cabd155c87..2d724f771e 100644 --- a/packages/frontend/src/directives/ripple.ts +++ b/packages/frontend/src/directives/ripple.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts index 5d6ec2928b..b1c1b19907 100644 --- a/packages/frontend/src/directives/tooltip.ts +++ b/packages/frontend/src/directives/tooltip.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts index e0fd10047a..7a008a4486 100644 --- a/packages/frontend/src/directives/user-preview.ts +++ b/packages/frontend/src/directives/user-preview.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -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/events.ts b/packages/frontend/src/events.ts index 90d5f6eede..d476aec04a 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -1,9 +1,13 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { EventEmitter } from 'eventemitter3'; +import * as Misskey from 'misskey-js'; -// TODO: 型付け -export const globalEvents = new EventEmitter(); +export const globalEvents = new EventEmitter<{ + themeChanged: () => void; + clientNotification: (notification: Misskey.entities.Notification) => void; + requestClearPageCache: () => void; +}>(); diff --git a/packages/frontend/src/filters/bytes.ts b/packages/frontend/src/filters/bytes.ts index d40b020a9e..49b44167d4 100644 --- a/packages/frontend/src/filters/bytes.ts +++ b/packages/frontend/src/filters/bytes.ts @@ -1,14 +1,14 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ export default (v, digits = 0) => { if (v == null) return '?'; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB']; if (v === 0) return '0'; const isMinus = v < 0; if (isMinus) v = -v; const i = Math.floor(Math.log(v) / Math.log(1024)); - return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; + return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/(\.[1-9]*)0+$/, '$1').replace(/\.$/, '') + (sizes[i] ?? `e+${ i * 3 }B`); }; diff --git a/packages/frontend/src/filters/date.ts b/packages/frontend/src/filters/date.ts index 23541f1094..2ffe93e868 100644 --- a/packages/frontend/src/filters/date.ts +++ b/packages/frontend/src/filters/date.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/filters/hms.ts b/packages/frontend/src/filters/hms.ts new file mode 100644 index 0000000000..7f90c92e99 --- /dev/null +++ b/packages/frontend/src/filters/hms.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { i18n } from '@/i18n.js'; + +export function hms(ms: number, options?: { + textFormat?: 'colon' | 'locale'; + enableSeconds?: boolean; + enableMs?: boolean; +}) { + const _options = { + textFormat: 'colon', + enableSeconds: true, + enableMs: false, + ...options, + }; + + const res: { + h?: string; + m?: string; + s?: string; + ms?: string; + } = {}; + + // ミリ秒を秒に変換 + let seconds = Math.floor(ms / 1000); + + // 小数点以下の値(2位まで) + const mili = ms - seconds * 1000; + + // 時間を計算 + const hours = Math.floor(seconds / 3600); + res.h = format(hours); + seconds %= 3600; + + // 分を計算 + const minutes = Math.floor(seconds / 60); + res.m = format(minutes); + seconds %= 60; + + // 残った秒数を取得 + seconds = seconds % 60; + res.s = format(seconds); + + // ミリ秒を取得 + res.ms = format(Math.floor(mili / 10)); + + // 結果を返す + if (_options.textFormat === 'locale') { + res.h += i18n.ts._time.hour; + res.m += i18n.ts._time.minute; + res.s += i18n.ts._time.second; + } + return [ + res.h.startsWith('00') ? undefined : res.h, + res.m, + (_options.enableSeconds ? res.s : undefined), + ].filter(v => v !== undefined).join(_options.textFormat === 'colon' ? ':' : ' ') + (_options.enableMs ? _options.textFormat === 'colon' ? `.${res.ms}` : ` ${res.ms}` : ''); +} + +function format(n: number) { + return n.toString().padStart(2, '0'); +} diff --git a/packages/frontend/src/filters/kmg.ts b/packages/frontend/src/filters/kmg.ts new file mode 100644 index 0000000000..4dcb5c5800 --- /dev/null +++ b/packages/frontend/src/filters/kmg.ts @@ -0,0 +1,9 @@ +export default (v, fractionDigits = 0) => { + if (v == null) return 'N/A'; + if (v === 0) return '0'; + const sizes = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'R', 'Q']; + const isMinus = v < 0; + if (isMinus) v = -v; + const i = Math.floor(Math.log(v) / Math.log(1000)); + return (isMinus ? '-' : '') + (v / Math.pow(1000, i)).toFixed(fractionDigits).replace(/(\.[1-9]*)0+$/, '$1').replace(/\.$/, '') + (sizes[i] ?? `e+${ i * 3 }`); +}; diff --git a/packages/frontend/src/filters/note.ts b/packages/frontend/src/filters/note.ts index 626d03a096..ce31021469 100644 --- a/packages/frontend/src/filters/note.ts +++ b/packages/frontend/src/filters/note.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/filters/number.ts b/packages/frontend/src/filters/number.ts index d0e4f4991f..2e7cc60ff4 100644 --- a/packages/frontend/src/filters/number.ts +++ b/packages/frontend/src/filters/number.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/filters/user.ts b/packages/frontend/src/filters/user.ts index 8d20603725..b713d41789 100644 --- a/packages/frontend/src/filters/user.ts +++ b/packages/frontend/src/filters/user.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 858db74dac..cc9faddb20 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,6 +10,7 @@ import { I18n } from '@/scripts/i18n.js'; export const i18n = markRaw(new I18n<Locale>(locale)); -export function updateI18n(newLocale) { - i18n.ts = newLocale; +export function updateI18n(newLocale: Locale) { + // @ts-expect-error -- private field + i18n.locale = newLocale; } diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 8de01e4802..ecd4f44713 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -1,5 +1,5 @@ <!-- - SPDX-FileCopyrightText: syuilo and other misskey contributors + SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,20 +16,22 @@ <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <meta http-equiv="Content-Security-Policy" - content="default-src 'self'; + content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/; worker-src 'self'; - script-src 'self' 'unsafe-eval'; + script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; - img-src 'self' data: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; + img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; - connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;" + connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; + frame-src *;" /> <meta property="og:site_name" content="[DEV BUILD] Misskey" /> <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="theme-color-orig" content="#86b300"> </head> <body> -<div id="misskey_app"></div> +<div id="sharkey_app"></div> <script type="module" src="./_dev_boot_.ts"></script> </body> </html> diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index b09264dabb..4232cbcd78 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -1,23 +1,34 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@/const.js'; // 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,8 +36,16 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL); -export async function fetchInstance() { - const meta = await api('meta', { +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 d95ff2119c..2b2f59edb3 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,11 +7,13 @@ type Keys = 'v' | 'lastVersion' | 'instance' | + 'instanceCachedAt' | 'account' | 'accounts' | 'latestDonationInfoShownAt' | 'neverShowDonationInfo' | 'neverShowLocalOnlyInfo' | + 'modifiedVersionMustProminentlyOfferInAgplV3Section13Read' | 'lastUsed' | 'lang' | 'drafts' | diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index d615c751ee..b71c15d19f 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -118,6 +118,11 @@ export const navbarItemDef = reactive({ show: computed(() => $i != null && instance.enableAchievements), to: '/my/achievements', }, + games: { + title: 'Games', + icon: 'ph-game-controller ph-bold ph-lg', + to: '/games', + }, ui: { title: i18n.ts.switchUi, icon: 'ph-devices ph-bold ph-lg', diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index 9755bdcb18..616fb104e6 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -1,24 +1,33 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ // NIRAX --- A lightweight router -import { EventEmitter } from 'eventemitter3'; import { Component, onMounted, shallowRef, ShallowRef } from 'vue'; +import { EventEmitter } from 'eventemitter3'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; -type RouteDef = { +interface RouteDefBase { path: string; - component: Component; query?: Record<string, string>; loginRequired?: boolean; name?: string; hash?: string; globalCacheKey?: string; children?: RouteDef[]; -}; +} + +interface RouteDefWithComponent extends RouteDefBase { + component: Component, +} + +interface RouteDefWithRedirect extends RouteDefBase { + redirect: string | ((props: Map<string, string | boolean>) => string); +} + +export type RouteDef = RouteDefWithComponent | RouteDefWithRedirect; type ParsedPath = (string | { name: string; @@ -27,7 +36,40 @@ type ParsedPath = (string | { optional?: boolean; })[]; -export type Resolved = { route: RouteDef; props: Map<string, string | boolean>; child?: Resolved; }; +export type RouterEvent = { + change: (ctx: { + beforePath: string; + path: string; + resolved: Resolved; + key: string; + }) => void; + replace: (ctx: { + path: string; + key: string; + }) => void; + push: (ctx: { + beforePath: string; + path: string; + route: RouteDef | null; + props: Map<string, string> | null; + key: string; + }) => void; + same: () => void; +} + +export type Resolved = { + route: RouteDef; + props: Map<string, string | boolean>; + child?: Resolved; + redirected?: boolean; + + /** @internal */ + _parsedRoute: { + fullPath: string; + queryString: string | null; + hash: string | null; + }; +}; function parsePath(path: string): ParsedPath { const res = [] as ParsedPath; @@ -54,34 +96,99 @@ function parsePath(path: string): ParsedPath { return res; } -export class Router extends EventEmitter<{ - change: (ctx: { - beforePath: string; - path: string; - resolved: Resolved; - key: string; - }) => void; - replace: (ctx: { - path: string; - key: string; - }) => void; - push: (ctx: { - beforePath: string; - path: string; - route: RouteDef | null; - props: Map<string, string> | null; - key: string; - }) => void; - same: () => void; -}> { +export interface IRouter extends EventEmitter<RouterEvent> { + current: Resolved; + currentRef: ShallowRef<Resolved>; + currentRoute: ShallowRef<RouteDef>; + navHook: ((path: string, flag?: any) => boolean) | null; + + /** + * ルートの初期化(eventListenerの定義後に必ず呼び出すこと) + */ + init(): void; + + resolve(path: string): Resolved | null; + + getCurrentPath(): any; + + getCurrentKey(): string; + + push(path: string, flag?: any): void; + + replace(path: string, key?: string | null): void; + + /** @see EventEmitter */ + eventNames(): Array<EventEmitter.EventNames<RouterEvent>>; + + /** @see EventEmitter */ + listeners<T extends EventEmitter.EventNames<RouterEvent>>( + event: T + ): Array<EventEmitter.EventListener<RouterEvent, T>>; + + /** @see EventEmitter */ + listenerCount( + event: EventEmitter.EventNames<RouterEvent> + ): number; + + /** @see EventEmitter */ + emit<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + ...args: EventEmitter.EventArgs<RouterEvent, T> + ): boolean; + + /** @see EventEmitter */ + on<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn: EventEmitter.EventListener<RouterEvent, T>, + context?: any + ): this; + + /** @see EventEmitter */ + addListener<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn: EventEmitter.EventListener<RouterEvent, T>, + context?: any + ): this; + + /** @see EventEmitter */ + once<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn: EventEmitter.EventListener<RouterEvent, T>, + context?: any + ): this; + + /** @see EventEmitter */ + removeListener<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn?: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + once?: boolean | undefined + ): this; + + /** @see EventEmitter */ + off<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn?: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + once?: boolean | undefined + ): this; + + /** @see EventEmitter */ + removeAllListeners( + event?: EventEmitter.EventNames<RouterEvent> + ): this; +} + +export class Router extends EventEmitter<RouterEvent> implements IRouter { private routes: RouteDef[]; public current: Resolved; - public currentRef: ShallowRef<Resolved> = shallowRef(); - public currentRoute: ShallowRef<RouteDef> = shallowRef(); + public currentRef: ShallowRef<Resolved>; + public currentRoute: ShallowRef<RouteDef>; private currentPath: string; private isLoggedIn: boolean; private notFoundPageComponent: Component; private currentKey = Date.now().toString(); + private redirectCount = 0; public navHook: ((path: string, flag?: any) => boolean) | null = null; @@ -89,13 +196,24 @@ export class Router extends EventEmitter<{ super(); this.routes = routes; + this.current = this.resolve(currentPath)!; + this.currentRef = shallowRef(this.current); + this.currentRoute = shallowRef(this.current.route); this.currentPath = currentPath; this.isLoggedIn = isLoggedIn; this.notFoundPageComponent = notFoundPageComponent; - this.navigate(currentPath, null, false); + } + + public init() { + const res = this.navigate(this.currentPath, null, false); + this.emit('replace', { + path: res._parsedRoute.fullPath, + key: this.currentKey, + }); } public resolve(path: string): Resolved | null { + const fullPath = path; let queryString: string | null = null; let hash: string | null = null; if (path[0] === '/') path = path.substring(1); @@ -108,6 +226,12 @@ export class Router extends EventEmitter<{ path = path.substring(0, path.indexOf('?')); } + const _parsedRoute = { + fullPath, + queryString, + hash, + }; + if (_DEV_) console.log('Routing: ', path, queryString); function check(routes: RouteDef[], _parts: string[]): Resolved | null { @@ -158,6 +282,7 @@ export class Router extends EventEmitter<{ route, props, child, + _parsedRoute, }; } else { continue forEachRouteLoop; @@ -183,6 +308,7 @@ export class Router extends EventEmitter<{ return { route, props, + _parsedRoute, }; } else { if (route.children) { @@ -192,6 +318,7 @@ export class Router extends EventEmitter<{ route, props, child, + _parsedRoute, }; } else { continue forEachRouteLoop; @@ -210,7 +337,7 @@ export class Router extends EventEmitter<{ return check(this.routes, _parts); } - private navigate(path: string, key: string | null | undefined, emitChange = true) { + private navigate(path: string, key: string | null | undefined, emitChange = true, _redirected = false): Resolved { const beforePath = this.currentPath; this.currentPath = path; @@ -220,6 +347,20 @@ export class Router extends EventEmitter<{ throw new Error('no route found for: ' + path); } + if ('redirect' in res.route) { + let redirectPath: string; + if (typeof res.route.redirect === 'function') { + redirectPath = res.route.redirect(res.props); + } else { + redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : ''); + } + if (_DEV_) console.log('Redirecting to: ', redirectPath); + if (_redirected && this.redirectCount++ > 10) { + throw new Error('redirect loop detected'); + } + return this.navigate(redirectPath, null, emitChange, true); + } + if (res.route.loginRequired && !this.isLoggedIn) { res.route.component = this.notFoundPageComponent; res.props.set('showLoginPopup', true); @@ -241,7 +382,11 @@ export class Router extends EventEmitter<{ }); } - return res; + this.redirectCount = 0; + return { + ...res, + redirected: _redirected, + }; } public getCurrentPath() { @@ -265,7 +410,7 @@ export class Router extends EventEmitter<{ const res = this.navigate(path, null); this.emit('push', { beforePath, - path, + path: res._parsedRoute.fullPath, route: res.route, props: res.props, key: this.currentKey, @@ -273,15 +418,20 @@ export class Router extends EventEmitter<{ } public replace(path: string, key?: string | null) { - this.navigate(path, key); + const res = this.navigate(path, key); + this.emit('replace', { + path: res._parsedRoute.fullPath, + key: this.currentKey, + }); } } -export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) { +export function useScrollPositionManager(getScrollContainer: () => HTMLElement | null, router: IRouter) { const scrollPosStore = new Map<string, number>(); onMounted(() => { const scrollContainer = getScrollContainer(); + if (scrollContainer == null) return; scrollContainer.addEventListener('scroll', () => { scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index b02f6aa640..fc73622d6b 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -1,16 +1,16 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する -import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api.js'; -export { pendingApiRequestsCount, api, apiGet }; 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 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'; import MkWaitingDialog from '@/components/MkWaitingDialog.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 = api(endpoint, data, token); + 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; @@ -83,12 +82,12 @@ export const apiWithDialog = (( }); return promise; -}) as typeof api; +}) as typeof misskeyApi; 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); @@ -109,10 +108,17 @@ export function promiseDialog<T extends Promise<any>>( if (onFailure) { onFailure(err); } else { - alert({ - type: 'error', - text: err, - }); + if (err.message) { + alert({ + type: 'error', + text: err.message, + }); + } else { + alert({ + type: 'error', + text: err, + }); + } } }); @@ -128,9 +134,10 @@ export function promiseDialog<T extends Promise<any>>( let popupIdCount = 0; export const popups = ref([]) as Ref<{ - id: any; - component: any; + id: number; + component: Component; props: Record<string, any>; + events: Record<string, any>; }[]>; const zIndexes = { @@ -144,7 +151,34 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number { return zIndexes[priority]; } -export async function popup(component: Component, props: Record<string, any>, events = {}, disposeEvent?: string) { +// InstanceType<typeof Component>['$emit'] だとインターセクション型が返ってきて +// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する +// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい +type ComponentEmit<T> = T extends new () => { $props: infer Props } + ? [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>, +): Promise<{ dispose: () => void }> { markRaw(component); const id = ++popupIdCount; @@ -185,12 +219,12 @@ export function toast(message: string) { export function alert(props: { type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; }): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, props, { - done: result => { + done: () => { resolve(); }, }, 'closed'); @@ -199,12 +233,12 @@ export function alert(props: { export function confirm(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; okText?: string; cancelText?: string; }): Promise<{ canceled: boolean }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { ...props, showCancelButton: true, @@ -225,13 +259,15 @@ export function actions<T extends { danger?: boolean, }[]>(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; actions: T; -}): Promise<{ canceled: true; result: undefined; } | { +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: T[number]['value']; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { ...props, actions: props.actions.map(a => ({ @@ -250,19 +286,50 @@ export function actions<T extends { }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; autocomplete?: string; - default?: string | 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, @@ -270,7 +337,7 @@ export function inputText(props: { type: props.type, placeholder: props.placeholder, autocomplete: props.autocomplete, - default: props.default, + default: props.default ?? null, minLength: props.minLength, maxLength: props.maxLength, }, @@ -282,16 +349,41 @@ export function inputText(props: { }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function inputNumber(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; autocomplete?: string; - default?: number | 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, @@ -299,7 +391,7 @@ export function inputNumber(props: { type: 'number', placeholder: props.placeholder, autocomplete: props.autocomplete, - default: props.default, + default: props.default ?? null, }, }, { done: result => { @@ -310,34 +402,38 @@ export function inputNumber(props: { } export function inputDate(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; - default?: Date | null; -}): Promise<{ canceled: true; result: undefined; } | { + default?: string | null; +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: Date; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, input: { type: 'date', placeholder: props.placeholder, - default: props.default, + default: props.default ?? null, }, }, { done: result => { - resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true }); + resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); }, }, 'closed'); }); } -export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | { +export function authenticateDialog(): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: { password: string; token: string | null; }; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkPasswordDialog, {}, { done: result => { resolve(result ? { canceled: false, result } : { canceled: true, result: undefined }); @@ -346,34 +442,53 @@ export function authenticateDialog(): Promise<{ canceled: true; result: undefine }); } +// default が指定されていたら result は null になり得ないことを保証する overload function +export function select<C = any>(props: { + 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 | null; - text?: string | null; + 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 => { @@ -384,7 +499,7 @@ export function select<C = any>(props: { } export function success(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const showing = ref(true); window.setTimeout(() => { showing.value = false; @@ -399,7 +514,7 @@ export function success(): Promise<void> { } export function waiting(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const showing = ref(true); popup(MkWaitingDialog, { success: false, @@ -410,9 +525,9 @@ export function waiting(): Promise<void> { }); } -export function form(title, form) { - return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { +export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> { + return new Promise(resolve => { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { done: result => { resolve(result); }, @@ -420,10 +535,11 @@ export function form(title, form) { }); } -export async function selectUser(opts: { includeSelf?: boolean } = {}) { - return new Promise((resolve, reject) => { +export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), { includeSelf: opts.includeSelf, + localOnly: opts.localOnly, }, { ok: user => { resolve(user); @@ -433,7 +549,7 @@ export async function selectUser(opts: { includeSelf?: boolean } = {}) { } 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, @@ -447,23 +563,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti }); } -export async function selectDriveFolder(multiple: boolean) { - return new Promise((resolve, reject) => { +export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'folder', multiple, }, { done: folders => { if (folders) { - resolve(multiple ? folders : folders[0]); + resolve(folders); } }, }, 'closed'); }); } -export async function pickEmoji(src: HTMLElement | null, opts) { - return new Promise((resolve, reject) => { +export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> { + return new Promise(resolve => { popup(MkEmojiPickerDialog, { src, ...opts, @@ -479,7 +595,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; uploadFolder?: string | null; }): Promise<Misskey.entities.DriveFile> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, @@ -492,67 +608,13 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { }); } -type AwaitType<T> = - T extends Promise<infer U> ? U : - T extends (...args: any[]) => Promise<infer V> ? V : - T; -let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null; -let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; -export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { - if (openingEmojiPicker) return; - - activeTextarea = initialTextarea; - - const textareas = document.querySelectorAll('textarea, input'); - for (const textarea of Array.from(textareas)) { - textarea.addEventListener('focus', () => { - activeTextarea = textarea; - }); - } - - const observer = new MutationObserver(records => { - for (const record of records) { - for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) { - const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>; - for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) { - if (document.activeElement === textarea) activeTextarea = textarea; - textarea.addEventListener('focus', () => { - activeTextarea = textarea; - }); - } - } - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - attributes: false, - characterData: false, - }); - - openingEmojiPicker = await popup(MkEmojiPickerWindow, { - src, - ...opts, - }, { - chosen: emoji => { - insertTextAtCursor(activeTextarea, emoji); - }, - closed: () => { - openingEmojiPicker!.dispose(); - openingEmojiPicker = null; - observer.disconnect(); - }, - }); -} - -export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: { +export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: { align?: string; width?: number; viaKeyboard?: boolean; onClosing?: () => void; }): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { let dispose; popup(MkPopupMenu, { items, @@ -574,9 +636,9 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement }); } -export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> { +export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { ev.preventDefault(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { let dispose; popup(MkContextMenu, { items, @@ -595,7 +657,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) export function post(props: Record<string, any> = {}): Promise<void> { showMovedDialog(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 @@ -621,7 +683,7 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> { const data = new FormData(); data.append('md5', getMD5(fileData)); - os.api('drive/files/find-by-hash', { + api('drive/files/find-by-hash', { md5: getMD5(fileData) }).then(resp => { resolve(resp.length > 0 ? resp[0] : null); diff --git a/packages/frontend/src/pages/_empty_.vue b/packages/frontend/src/pages/_empty_.vue index 9403d862c2..236d3fa14d 100644 --- a/packages/frontend/src/pages/_empty_.vue +++ b/packages/frontend/src/pages/_empty_.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue index 2cdf8f2e8c..6b1eff5bcb 100644 --- a/packages/frontend/src/pages/_error_.vue +++ b/packages/frontend/src/pages/_error_.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div>{{ i18n.ts.youShouldUpgradeClient }}</div> <MkButton style="margin: 8px auto;" @click="reload">{{ i18n.ts.reload }}</MkButton> </template> - <div><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></div> + <div><MkLink url="https://misskey-hub.net/docs/for-users/resources/troubleshooting/" target="_blank">{{ i18n.ts.troubleshooting }}</MkLink></div> <div v-if="error" style="opacity: 0.7;">ERROR: {{ error }}</div> </div> </div> @@ -28,8 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; +import MkLink from '@/components/MkLink.vue'; import { version } from '@/config.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -46,7 +47,7 @@ const loaded = ref(false); const serverIsDead = ref(false); const meta = ref<Misskey.entities.MetaResponse | null>(null); -os.api('meta', { +misskeyApi('meta', { detail: false, }).then(res => { loaded.value = true; @@ -66,10 +67,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.error, icon: 'ph-warning ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/_loading_.vue b/packages/frontend/src/pages/_loading_.vue index 9f3c9fd355..5175979642 100644 --- a/packages/frontend/src/pages/_loading_.vue +++ b/packages/frontend/src/pages/_loading_.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/about-sharkey.vue b/packages/frontend/src/pages/about-sharkey.vue index 2e4ff5d041..30788e24ce 100644 --- a/packages/frontend/src/pages/about-sharkey.vue +++ b/packages/frontend/src/pages/about-sharkey.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="misskey">Sharkey</div> <div class="version">v{{ version }}</div> <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"> - <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true"/> + <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true" :fallbackToImage="true"/> <MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :noStyle="true"/> </span> </div> @@ -27,33 +27,66 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="$i != null" style="text-align: center;"> <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Sharkey</MkButton> </div> - <FormSection> + <FormSection v-if="instance.repositoryUrl !== 'https://activitypub.software/TransFem-org/Sharkey/'"> <div class="_gaps_s"> - <FormLink to="https://git.joinsharkey.org/Sharkey/Sharkey" external> + <MkInfo> + {{ i18n.tsx._aboutMisskey.thisIsModifiedVersion({ name: instance.name }) }} + </MkInfo> + <FormLink v-if="instance.repositoryUrl" :to="instance.repositoryUrl" external> <template #icon><i class="ph-code ph-bold ph-lg"></i></template> {{ i18n.ts._aboutMisskey.source }} - <template #suffix>Forgejo</template> + </FormLink> + <FormLink v-if="instance.providesTarball" :to="`/tarball/sharkey-${version}.tar.gz`" external> + <template #icon><i class="ph-download ph-bold ph-lg"></i></template> + {{ i18n.ts._aboutMisskey.source }} + <template #suffix>Tarball</template> + </FormLink> + <MkInfo v-if="!instance.repositoryUrl && !instance.providesTarball" warn> + {{ i18n.ts.sourceCodeIsNotYetProvided }} + </MkInfo> + </div> + </FormSection> + <FormSection> + <div class="_gaps_s"> + <FormLink to="https://activitypub.software/TransFem-org/Sharkey/" external> + <template #icon><i class="ph-code ph-bold ph-lg"></i></template> + {{ i18n.ts._aboutMisskey.source }} ({{ i18n.ts._aboutMisskey.original_sharkey }}) + <template #suffix>GitLab</template> </FormLink> <FormLink to="https://ko-fi.com/transfem" external> <template #icon><i class="ph-piggy-bank ph-bold ph-lg"></i></template> - {{ i18n.ts._aboutMisskey.donate }} + {{ i18n.ts._aboutMisskey.donate_sharkey }} <template #suffix>Ko-Fi</template> </FormLink> </div> </FormSection> <FormSection> + <div class="_gaps_s"> + <FormLink to="https://github.com/misskey-dev/misskey" external> + <template #icon><i class="ph-code ph-bold ph-lg"></i></template> + {{ i18n.ts._aboutMisskey.source }} ({{ i18n.ts._aboutMisskey.original }}) + <template #suffix>GitHub</template> + </FormLink> + <FormLink to="https://www.patreon.com/syuilo" external> + <template #icon><i class="ph-piggy-bank ph-bold ph-lg"></i></template> + {{ i18n.ts._aboutMisskey.donate }} + <template #suffix>Patreon</template> + </FormLink> + </div> + </FormSection> + <FormSection> <template #label>{{ i18n.ts._aboutMisskey.projectMembers }}</template> <div :class="$style.contributors" style="margin-bottom: 8px;"> - <a href="https://git.joinsharkey.org/Marie" target="_blank" :class="$style.contributor"> - <img src="https://git.joinsharkey.org/avatar/0d57abf583f5ed6cf37f47055a1e1aa4?size=512" :class="$style.contributorAvatar"> + <a href="https://activitypub.software/Marie" target="_blank" :class="$style.contributor"> + <img src="https://activitypub.software/uploads/-/system/user/avatar/2/avatar.png?width=128" :class="$style.contributorAvatar"> <span :class="$style.contributorUsername">@Marie</span> </a> - <a href="https://git.joinsharkey.org/Amelia" target="_blank" :class="$style.contributor"> - <img src="https://git.joinsharkey.org/avatars/0634b661b89d6e45137074b6ddcd0b9ffc4cf467f2188ec12416ec6f91bb9d42?size=512" :class="$style.contributorAvatar"> + <a href="https://activitypub.software/Amelia" target="_blank" :class="$style.contributor"> + <img src="https://activitypub.software/uploads/-/system/user/avatar/1/avatar.png?width=128" :class="$style.contributorAvatar"> <span :class="$style.contributorUsername">@Amelia</span> </a> </div> - <template #caption><MkLink url="https://git.joinsharkey.org/Sharkey/Sharkey/graph">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> + <template #caption><MkLink url="https://activitypub.software/TransFem-org/Sharkey/-/graphs/develop">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> </FormSection> <FormSection> <template #label>Misskey Contributors</template> @@ -88,7 +121,7 @@ SPDX-License-Identifier: AGPL-3.0-only </a> </div> </FormSection> - <FormSection> + <FormSection v-if="sponsors[0].length > 0"> <template #label>Our lovely Sponsors</template> <div :class="$style.contributors"> <span @@ -116,10 +149,13 @@ import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; import MkLink from '@/components/MkLink.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { physics } from '@/scripts/physics.js'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; import { $i } from '@/account.js'; @@ -132,7 +168,7 @@ const easterEggEngine = ref(null); const sponsors = ref([]); const containerEl = shallowRef<HTMLElement>(); -await os.api('sponsors', { forceUpdate: true }).then((res) => sponsors.value.push(res.sponsor_data)); +await misskeyApi('sponsors', { forceUpdate: true }).then((res) => sponsors.value.push(res.sponsor_data)); function iconLoaded() { const emojis = defaultStore.state.reactions; @@ -179,10 +215,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.aboutMisskey, icon: null, -}); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index eda6455fd6..f37e9dbf96 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 0de000ee3e..c7f2315faa 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,10 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="federating">{{ i18n.ts.federating }}</option> <option value="subscribing">{{ i18n.ts.subscribing }}</option> <option value="publishing">{{ i18n.ts.publishing }}</option> + <option value="bubble">Bubble</option> <option value="nsfw">NSFW</option> - <option value="suspended">{{ i18n.ts.suspended }}</option> - <option value="silenced">{{ i18n.ts.silence }}</option> - <option value="blocked">{{ i18n.ts.blocked }}</option> + <option v-if="$i" value="suspended">{{ i18n.ts.suspended }}</option> + <option v-if="$i" value="silenced">{{ i18n.ts.silence }}</option> + <option v-if="$i" value="blocked">{{ i18n.ts.blocked }}</option> <option value="notResponding">{{ i18n.ts.notResponding }}</option> </MkSelect> <MkSelect v-model="sort"> @@ -59,6 +60,7 @@ import MkPagination, { Paging } from '@/components/MkPagination.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; +import { $i } from '@/account.js'; const host = ref(''); const state = ref('federating'); @@ -80,6 +82,7 @@ const pagination = { state.value === 'silenced' ? { silenced: true } : state.value === 'notResponding' ? { notResponding: true } : state.value === 'nsfw' ? { nsfw: true } : + state.value === 'bubble' ? { bubble: true } : {}), })), } as Paging; diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index b532314745..f2aceada7d 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -1,107 +1,136 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> - <div class="_gaps_m"> - <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> - <div style="overflow: clip;"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> - <div :class="$style.bannerName"> - <b>{{ instance.name ?? host }}</b> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> + <div class="_gaps_m"> + <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> + <div style="overflow: clip;"> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> + <div :class="$style.bannerName"> + <b>{{ instance.name ?? host }}</b> + </div> </div> </div> - </div> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value><div v-html="instance.description"></div></template> - </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value><div v-html="sanitizeHtml(instance.description)"></div></template> + </MkKeyValue> - <FormSection> - <div class="_gaps_m"> - <MkKeyValue :copy="version"> - <template #key>Sharkey</template> - <template #value>{{ version }}</template> - </MkKeyValue> - <div v-html="i18n.t('poweredByMisskeyDescription', { name: instance.name ?? host })"> + <FormSection> + <div class="_gaps_m"> + <MkKeyValue :copy="version"> + <template #key>Sharkey</template> + <template #value>{{ version }}</template> + </MkKeyValue> + <div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })"> + </div> + <FormLink to="/about-sharkey"> + <template #icon><i class="ph-info ph-bold ph-lg"></i></template> + {{ i18n.ts.aboutMisskey }} + </FormLink> + <FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/sharkey-${version}.tar.gz`" external> + <template #icon><i class="ph-code ph-bold ph-lg"></i></template> + {{ i18n.ts.sourceCode }} + </FormLink> + <MkInfo v-else warn> + {{ i18n.ts.sourceCodeIsNotYetProvided }} + </MkInfo> </div> - <FormLink to="/about-sharkey">{{ i18n.ts.aboutMisskey }}</FormLink> - </div> - </FormSection> + </FormSection> - <FormSection> - <div class="_gaps_m"> - <FormSplit> - <MkKeyValue> - <template #key>{{ i18n.ts.administrator }}</template> - <template #value>{{ instance.maintainerName }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.contact }}</template> - <template #value>{{ instance.maintainerEmail }}</template> - </MkKeyValue> - </FormSplit> - <FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink> - <div class="_gaps_s"> - <MkFolder v-if="instance.serverRules.length > 0"> - <template #label>{{ i18n.ts.serverRules }}</template> + <FormSection> + <div class="_gaps_m"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ instance.maintainerName }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.contact }}</template> + <template #value>{{ instance.maintainerEmail }}</template> + </MkKeyValue> + </FormSplit> + <FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external> + <template #icon><i class="ph-newspaper-clipping ph-bold ph-lg"></i></template> + {{ i18n.ts.impressum }} + </FormLink> + <div class="_gaps_s"> + <MkFolder v-if="instance.serverRules.length > 0"> + <template #label> + <i class="ph-list-checks ph-bold ph-lg"></i> + {{ i18n.ts.serverRules }} + </template> - <ol class="_gaps_s" :class="$style.rules"> - <li v-for="item, index in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li> - </ol> - </MkFolder> - <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink> - <FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink> + <ol class="_gaps_s" :class="$style.rules"> + <li v-for="(item, index) in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="sanitizeHtml(item)"></div></li> + </ol> + </MkFolder> + <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external> + <template #icon><i class="ph-notebook ph-bold ph-lg"></i></template> + {{ i18n.ts.termsOfService }} + </FormLink> + <FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external> + <template #icon><i class="ph-shield ph-bold ph-lg"></i></template> + {{ i18n.ts.privacyPolicy }} + </FormLink> + <FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external> + <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> + {{ i18n.ts.feedback }} + </FormLink> + </div> </div> - </div> - </FormSection> + </FormSection> + + <FormSuspense :p="initStats"> + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + </FormSuspense> - <FormSuspense :p="initStats"> <FormSection> - <template #label>{{ i18n.ts.statistics }}</template> - <FormSplit> - <MkKeyValue> - <template #key>{{ i18n.ts.users }}</template> - <template #value>{{ number(stats.originalUsersCount) }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.notes }}</template> - <template #value>{{ number(stats.originalNotesCount) }}</template> - </MkKeyValue> - </FormSplit> + <template #label>Well-known resources</template> + <div class="_gaps_s"> + <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> + </div> </FormSection> - </FormSuspense> - - <FormSection> - <template #label>Well-known resources</template> - <div class="_gaps_s"> - <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> - <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> - <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> - <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> - <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> - </div> - </FormSection> - </div> - </MkSpacer> - <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> - <XEmojis/> - </MkSpacer> - <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> - <XFederation/> - </MkSpacer> - <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> - <MkInstanceStats/> - </MkSpacer> + </div> + </MkSpacer> + <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> + <XEmojis/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20"> + <XFederation/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> + <MkInstanceStats/> + </MkSpacer> + </MkHorizontalSwipe> </MkStickyContainer> </template> <script lang="ts" setup> +import sanitizeHtml from 'sanitize-html'; import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XEmojis from './about.emojis.vue'; @@ -113,8 +142,10 @@ import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkInfo from '@/components/MkInfo.vue'; import MkInstanceStats from '@/components/MkInstanceStats.vue'; -import * as os from '@/os.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -136,7 +167,7 @@ watch(tab, () => { } }); -const initStats = () => os.api('stats', { +const initStats = () => misskeyApi('stats', { }).then((res) => { stats.value = res; }); @@ -160,10 +191,10 @@ const headerTabs = computed(() => [{ icon: 'ph-chart-line ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.instanceInfo, icon: 'ph-info ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue index f735da7e67..4e496c3c6c 100644 --- a/packages/frontend/src/pages/achievements.vue +++ b/packages/frontend/src/pages/achievements.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -48,10 +48,10 @@ onDeactivated(() => { } }); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.achievements, icon: 'ph-trophy ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 845beebbaf..b8f7e2c163 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -79,6 +79,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { iAmAdmin, iAmModerator } from '@/account.js'; @@ -93,8 +94,8 @@ const props = defineProps<{ }>(); async function fetch() { - file.value = await os.api('drive/files/show', { fileId: props.fileId }); - info.value = await os.api('admin/drive/show-file', { fileId: props.fileId }); + file.value = await misskeyApi('drive/files/show', { fileId: props.fileId }); + info.value = await misskeyApi('admin/drive/show-file', { fileId: props.fileId }); isSensitive.value = file.value.isSensitive; } @@ -103,7 +104,7 @@ fetch(); async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: file.value.name }), + text: i18n.tsx.removeAreYouSure({ x: file.value.name }), }); if (canceled) return; @@ -113,7 +114,7 @@ async function del() { } async function toggleIsSensitive(v) { - await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v }); + await misskeyApi('drive/files/update', { fileId: props.fileId, isSensitive: v }); isSensitive.value = v; } @@ -139,10 +140,10 @@ const headerTabs = computed(() => [{ icon: 'ph-code ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ - title: file.value ? i18n.ts.file + ': ' + file.value.name : i18n.ts.file, +definePageMetadata(() => ({ + title: file.value ? `${i18n.ts.file}: ${file.value.name}` : i18n.ts.file, icon: 'ph-file ph-bold ph-lg', -}))); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 741897b5f0..3beaf5d08b 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -124,7 +124,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-for="role in info.roles" :key="role.id"> <div :class="$style.roleItemMain"> <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/> - <button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ph-caret-down ph-bold ph-lg"></i></button> + <button class="_button" @click="toggleRoleItem(role)"><i class="ph-caret-down ph-bold ph-lg"></i></button> <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ph-x ph-bold ph-lg"></i></button> <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ph-prohibit ph-bold ph-lg"></i></button> </div> @@ -169,9 +169,9 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSelect> </div> <div class="charts"> - <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div> <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> - <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div> <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> </div> </div> @@ -206,11 +206,12 @@ import FormSuspense from '@/components/form/suspense.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { url } from '@/config.js'; import { acct } from '@/filters/user.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; -import { iAmAdmin, $i } from '@/account.js'; +import { iAmAdmin, $i, iAmModerator } from '@/account.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -251,11 +252,11 @@ const announcementsPagination = { const expandedRoles = ref([]); function createFetcher() { - return () => Promise.all([os.api('users/show', { + return () => Promise.all([misskeyApi('users/show', { userId: props.userId, - }), os.api('admin/show-user', { + }), misskeyApi('admin/show-user', { userId: props.userId, - }), iAmAdmin ? os.api('admin/get-user-ips', { + }), iAmAdmin ? misskeyApi('admin/get-user-ips', { userId: props.userId, }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { user.value = _user; @@ -268,7 +269,7 @@ function createFetcher() { moderationNote.value = info.value.moderationNote; watch(moderationNote, async () => { - await os.api('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); + await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); await refreshUser(); }); }); @@ -291,12 +292,12 @@ async function resetPassword() { if (confirm.canceled) { return; } else { - const { password } = await os.api('admin/reset-password', { + const { password } = await misskeyApi('admin/reset-password', { userId: user.value.id, }); os.alert({ type: 'success', - text: i18n.t('newPasswordIs', { password }), + text: i18n.tsx.newPasswordIs({ password }), }); } } @@ -309,7 +310,7 @@ async function toggleNSFW(v) { if (confirm.canceled) { markedAsNSFW.value = !v; } else { - await os.api(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: user.value.id }); + await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: user.value.id }); await refreshUser(); } } @@ -322,7 +323,7 @@ async function toggleSilence(v) { if (confirm.canceled) { silenced.value = !v; } else { - await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.value.id }); + await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.value.id }); await refreshUser(); } } @@ -335,7 +336,7 @@ async function toggleSuspend(v) { if (confirm.canceled) { suspended.value = !v; } else { - await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.value.id }); + await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.value.id }); await refreshUser(); } } @@ -347,7 +348,7 @@ async function unsetUserAvatar() { }); if (confirm.canceled) return; const process = async () => { - await os.api('admin/unset-user-avatar', { userId: user.value.id }); + await misskeyApi('admin/unset-user-avatar', { userId: user.value.id }); os.success(); }; await process().catch(err => { @@ -366,7 +367,7 @@ async function unsetUserBanner() { }); if (confirm.canceled) return; const process = async () => { - await os.api('admin/unset-user-banner', { userId: user.value.id }); + await misskeyApi('admin/unset-user-banner', { userId: user.value.id }); os.success(); }; await process().catch(err => { @@ -385,7 +386,7 @@ async function deleteAllFiles() { }); if (confirm.canceled) return; const process = async () => { - await os.api('admin/delete-all-files-of-a-user', { userId: user.value.id }); + await misskeyApi('admin/delete-all-files-of-a-user', { userId: user.value.id }); os.success(); }; await process().catch(err => { @@ -405,7 +406,7 @@ async function deleteAccount() { if (confirm.canceled) return; const typed = await os.inputText({ - text: i18n.t('typeToConfirm', { x: user.value?.username }), + text: i18n.tsx.typeToConfirm({ x: user.value?.username }), }); if (typed.canceled) return; @@ -422,7 +423,7 @@ async function deleteAccount() { } async function assignRole() { - const roles = await os.api('admin/roles/list'); + const roles = await misskeyApi('admin/roles/list'); const { canceled, result: roleId } = await os.select({ title: i18n.ts._role.chooseRoleToAssign, @@ -498,7 +499,7 @@ watch(() => props.userId, () => { }); watch(user, () => { - os.api('ap/get', { + misskeyApi('ap/get', { uri: user.value.uri ?? `${url}/users/${user.value.id}`, }).then(res => { ap.value = res; @@ -533,10 +534,10 @@ const headerTabs = computed(() => [{ icon: 'ph-code ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: user.value ? acct(user.value) : i18n.ts.userInfo, icon: 'ph-warning-circle ph-bold ph-lg', -}))); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 92010f771c..1dbcc867a1 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index 6f1a31616a..5c9c32c964 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -1,16 +1,16 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick"> - <template v-if="metadata"> + <template v-if="pageMetadata"> <div class="titleContainer" @click="showTabsPopup"> - <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i> + <i v-if="pageMetadata.icon" class="icon" :class="pageMetadata.icon"></i> <div class="title"> - <div class="title">{{ metadata.title }}</div> + <div class="title">{{ pageMetadata.title }}</div> </div> </div> <div class="tabs"> @@ -39,7 +39,7 @@ import { popupMenu } from '@/os.js'; import { scrollToTop } from '@/scripts/scroll.js'; import MkButton from '@/components/MkButton.vue'; import { globalEvents } from '@/events.js'; -import { injectPageMetadata } from '@/scripts/page-metadata.js'; +import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; type Tab = { key?: string | null; @@ -65,7 +65,7 @@ const emit = defineEmits<{ (ev: 'update:tab', key: string); }>(); -const metadata = injectPageMetadata(); +const pageMetadata = injectReactiveMetadata(); const el = shallowRef<HTMLElement>(null); const tabRefs = {}; @@ -118,7 +118,7 @@ function onTabClick(tab: Tab, ev: MouseEvent): void { } const calcBg = () => { - const rawBg = metadata?.bg ?? 'var(--bg)'; + const rawBg = pageMetadata.value?.bg ?? 'var(--bg)'; const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); tinyBg.setAlpha(0.85); bg.value = tinyBg.toRgbString(); diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 92688989d2..42fcc3a598 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -87,8 +87,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.abuseReports, icon: 'ph-warning-circle ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 8a1e03c30d..6ec5abd2f2 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -96,6 +96,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -108,7 +109,7 @@ const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, const filterType = ref('all'); let publishing: boolean | null = null; -os.api('admin/ad/list', { publishing: publishing }).then(adsResponse => { +misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { if (adsResponse != null) { ads.value = adsResponse.map(r => { const exdate = new Date(r.expiresAt); @@ -159,7 +160,7 @@ function add() { function remove(ad) { os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: ad.url }), + text: i18n.tsx.removeAreYouSure({ x: ad.url }), }).then(({ canceled }) => { if (canceled) return; ads.value = ads.value.filter(x => x !== ad); @@ -174,7 +175,7 @@ function remove(ad) { function save(ad) { if (ad.id == null) { - os.api('admin/ad/create', { + misskeyApi('admin/ad/create', { ...ad, expiresAt: new Date(ad.expiresAt).getTime(), startsAt: new Date(ad.startsAt).getTime(), @@ -191,7 +192,7 @@ function save(ad) { }); }); } else { - os.api('admin/ad/update', { + misskeyApi('admin/ad/update', { ...ad, expiresAt: new Date(ad.expiresAt).getTime(), startsAt: new Date(ad.startsAt).getTime(), @@ -210,7 +211,7 @@ function save(ad) { } function more() { - os.api('admin/ad/list', { untilId: ads.value.reduce((acc, ad) => ad.id != null ? ad : acc).id, publishing: publishing }).then(adsResponse => { + misskeyApi('admin/ad/list', { untilId: ads.value.reduce((acc, ad) => ad.id != null ? ad : acc).id, publishing: publishing }).then(adsResponse => { if (adsResponse == null) return; ads.value = ads.value.concat(adsResponse.map(r => { const exdate = new Date(r.expiresAt); @@ -227,7 +228,7 @@ function more() { } function refresh() { - os.api('admin/ad/list', { publishing: publishing }).then(adsResponse => { + misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { if (adsResponse == null) return; ads.value = adsResponse.map(r => { const exdate = new Date(r.expiresAt); @@ -254,10 +255,10 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.ads, icon: 'ph-flag ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 931bd9bbc8..a8832b99fd 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> {{ i18n.ts._announcement.needConfirmationToRead }} </MkSwitch> - <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> + <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p> <div class="buttons _buttons"> <MkButton class="button" inline primary @click="save(announcement)"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> <MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> @@ -79,6 +79,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkFolder from '@/components/MkFolder.vue'; @@ -86,7 +87,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; const announcements = ref<any[]>([]); -os.api('admin/announcements/list').then(announcementResponse => { +misskeyApi('admin/announcements/list').then(announcementResponse => { announcements.value = announcementResponse; }); @@ -108,11 +109,11 @@ function add() { function del(announcement) { os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: announcement.title }), + text: i18n.tsx.deleteAreYouSure({ x: announcement.title }), }).then(({ canceled }) => { if (canceled) return; announcements.value = announcements.value.filter(x => x !== announcement); - os.api('admin/announcements/delete', announcement); + misskeyApi('admin/announcements/delete', announcement); }); } @@ -134,13 +135,13 @@ async function save(announcement) { } function more() { - os.api('admin/announcements/list', { untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id }).then(announcementResponse => { + misskeyApi('admin/announcements/list', { untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id }).then(announcementResponse => { announcements.value = announcements.value.concat(announcementResponse); }); } function refresh() { - os.api('admin/announcements/list').then(announcementResponse => { + misskeyApi('admin/announcements/list').then(announcementResponse => { announcements.value = announcementResponse; }); } @@ -156,8 +157,8 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.announcements, icon: 'ph-megaphone ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/approvals.vue b/packages/frontend/src/pages/admin/approvals.vue index 7d0535bd7f..998e16681a 100644 --- a/packages/frontend/src/pages/admin/approvals.vue +++ b/packages/frontend/src/pages/admin/approvals.vue @@ -48,10 +48,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.approvals, icon: 'ph-chalkboard-teacher ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index eebea51bf1..052d2f0bc2 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRadios v-model="provider"> <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> <option value="hcaptcha">hCaptcha</option> + <option value="mcaptcha">mCaptcha</option> <option value="recaptcha">reCAPTCHA</option> <option value="turnstile">Turnstile</option> </MkRadios> @@ -28,6 +29,24 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> </FormSlot> </template> + <template v-else-if="provider === 'mcaptcha'"> + <MkInput v-model="mcaptchaSiteKey"> + <template #prefix><i class="ph-key ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.mcaptchaSiteKey }}</template> + </MkInput> + <MkInput v-model="mcaptchaSecretKey"> + <template #prefix><i class="ph-key ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.mcaptchaSecretKey }}</template> + </MkInput> + <MkInput v-model="mcaptchaInstanceUrl"> + <template #prefix><i class="ph-globe-simple ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template> + </MkInput> + <FormSlot v-if="mcaptchaSiteKey && mcaptchaInstanceUrl"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="mcaptcha" :sitekey="mcaptchaSiteKey" :instanceUrl="mcaptchaInstanceUrl"/> + </FormSlot> + </template> <template v-else-if="provider === 'recaptcha'"> <MkInput v-model="recaptchaSiteKey"> <template #prefix><i class="ph-key ph-bold ph-lg"></i></template> @@ -72,6 +91,7 @@ import MkButton from '@/components/MkButton.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; @@ -80,21 +100,30 @@ const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue' const provider = ref<CaptchaProvider | null>(null); const hcaptchaSiteKey = ref<string | null>(null); const hcaptchaSecretKey = ref<string | null>(null); +const mcaptchaSiteKey = ref<string | null>(null); +const mcaptchaSecretKey = ref<string | null>(null); +const mcaptchaInstanceUrl = ref<string | null>(null); const recaptchaSiteKey = ref<string | null>(null); const recaptchaSecretKey = ref<string | null>(null); const turnstileSiteKey = ref<string | null>(null); const turnstileSecretKey = ref<string | null>(null); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); hcaptchaSiteKey.value = meta.hcaptchaSiteKey; hcaptchaSecretKey.value = meta.hcaptchaSecretKey; + mcaptchaSiteKey.value = meta.mcaptchaSiteKey; + mcaptchaSecretKey.value = meta.mcaptchaSecretKey; + mcaptchaInstanceUrl.value = meta.mcaptchaInstanceUrl; recaptchaSiteKey.value = meta.recaptchaSiteKey; recaptchaSecretKey.value = meta.recaptchaSecretKey; turnstileSiteKey.value = meta.turnstileSiteKey; turnstileSecretKey.value = meta.turnstileSecretKey; - provider.value = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null; + provider.value = meta.enableHcaptcha ? 'hcaptcha' : + meta.enableRecaptcha ? 'recaptcha' : + meta.enableTurnstile ? 'turnstile' : + meta.enableMcaptcha ? 'mcaptcha' : null; } function save() { @@ -102,6 +131,10 @@ function save() { enableHcaptcha: provider.value === 'hcaptcha', hcaptchaSiteKey: hcaptchaSiteKey.value, hcaptchaSecretKey: hcaptchaSecretKey.value, + enableMcaptcha: provider.value === 'mcaptcha', + mcaptchaSiteKey: mcaptchaSiteKey.value, + mcaptchaSecretKey: mcaptchaSecretKey.value, + mcaptchaInstanceUrl: mcaptchaInstanceUrl.value, enableRecaptcha: provider.value === 'recaptcha', recaptchaSiteKey: recaptchaSiteKey.value, recaptchaSecretKey: recaptchaSecretKey.value, @@ -109,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 fc6a9e0d67..9310f52bfb 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template> <template #caption> - <div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div> + <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> - <div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '192x192px' }) }}</strong></div> + <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div> </template> </MkInput> @@ -30,10 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template> <template #caption> - <div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div> + <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> - <div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '512x512px' }) }}</strong></div> + <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div> </template> </MkInput> @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FromSlot> <template #label>{{ i18n.ts.defaultLike }}</template> - <MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="defaultLike" :normal="true" :noStyle="true"/> + <MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="defaultLike" :normal="true" :noStyle="true"/> <MkEmoji v-else :emoji="defaultLike" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/> <MkButton rounded :small="true" @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton> </FromSlot> @@ -83,6 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> </MkTextarea> + <MkInput v-model="repositoryUrl" type="url"> + <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.repositoryUrl }}</template> + </MkInput> + + <MkInput v-model="feedbackUrl" type="url"> + <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.feedbackUrl }}</template> + </MkInput> + <MkTextarea v-model="manifestJsonOverride"> <template #label>{{ i18n.ts._serverSettings.manifestJsonOverride }}</template> </MkTextarea> @@ -109,6 +119,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import FromSlot from '@/components/form/slot.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { instance, fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -128,10 +139,12 @@ const defaultLike = ref<string>(''); const serverErrorImageUrl = ref<string | null>(null); const infoImageUrl = ref<string | null>(null); const notFoundImageUrl = ref<string | null>(null); +const repositoryUrl = ref<string | null>(null); +const feedbackUrl = ref<string | null>(null); const manifestJsonOverride = ref<string>('{}'); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); iconUrl.value = meta.iconUrl; app192IconUrl.value = meta.app192IconUrl; app512IconUrl.value = meta.app512IconUrl; @@ -144,6 +157,8 @@ async function init() { serverErrorImageUrl.value = meta.serverErrorImageUrl; infoImageUrl.value = meta.infoImageUrl; notFoundImageUrl.value = meta.notFoundImageUrl; + repositoryUrl.value = meta.repositoryUrl; + feedbackUrl.value = meta.feedbackUrl; manifestJsonOverride.value = meta.manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON.parse(meta.manifestJsonOverride), null, '\t'); } @@ -157,12 +172,14 @@ function save() { themeColor: themeColor.value === '' ? null : themeColor.value, defaultLightTheme: defaultLightTheme.value === '' ? null : defaultLightTheme.value, defaultDarkTheme: defaultDarkTheme.value === '' ? null : defaultDarkTheme.value, - infoImageUrl: infoImageUrl.value, - notFoundImageUrl: notFoundImageUrl.value, - serverErrorImageUrl: serverErrorImageUrl.value, + infoImageUrl: infoImageUrl.value === '' ? null : infoImageUrl.value, + notFoundImageUrl: notFoundImageUrl.value === '' ? null : notFoundImageUrl.value, + serverErrorImageUrl: serverErrorImageUrl.value === '' ? null : serverErrorImageUrl.value, + repositoryUrl: repositoryUrl.value === '' ? null : repositoryUrl.value, + feedbackUrl: feedbackUrl.value === '' ? null : feedbackUrl.value, manifestJsonOverride: manifestJsonOverride.value === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride.value)), }).then(() => { - fetchInstance(); + fetchInstance(true); }); } @@ -181,10 +198,10 @@ function chooseNewLike(ev: MouseEvent) { const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.branding, icon: 'ph-paint-roller ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue index d9fc672fbf..a64e07b4c7 100644 --- a/packages/frontend/src/pages/admin/database.vue +++ b/packages/frontend/src/pages/admin/database.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,20 +21,20 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import bytes from '@/filters/bytes.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); +const databasePromiseFactory = () => misskeyApi('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.database, icon: 'ph-database ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index 819619df90..1cbfdab094 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -73,6 +73,7 @@ import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -87,7 +88,7 @@ const smtpUser = ref<string>(''); const smtpPass = ref<string>(''); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); enableEmail.value = meta.enableEmail; email.value = meta.email; smtpSecure.value = meta.smtpSecure; @@ -123,16 +124,16 @@ function save() { smtpUser: smtpUser.value, smtpPass: smtpPass.value, }).then(() => { - fetchInstance(); + fetchInstance(true); }); } const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.emailServer, icon: 'ph-envelope ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue index f4359270b6..728cdc60b0 100644 --- a/packages/frontend/src/pages/admin/external-services.vue +++ b/packages/frontend/src/pages/admin/external-services.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,6 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="deeplIsPro"> <template #label>Pro account</template> </MkSwitch> + <MkSwitch v-model="deeplFreeMode"> + <template #label>{{ i18n.ts.deeplFreeMode }}</template> + </MkSwitch> + <MkInput v-if="deeplFreeMode" v-model="deeplFreeInstance" :placeholder="'example.com/translate'"> + <template #prefix><i class="ph-globe-simple ph-bold ph-lg"></i></template> + <template #label>DeepLX-JS URL</template> + <template #caption>{{ i18n.ts.deeplFreeModeDescription }}</template> + </MkInput> </div> </FormSection> </FormSuspense> @@ -42,25 +50,32 @@ import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; const deeplAuthKey = ref<string>(''); const deeplIsPro = ref<boolean>(false); +const deeplFreeMode = ref<boolean>(false); +const deeplFreeInstance = ref<string>(''); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); deeplAuthKey.value = meta.deeplAuthKey; deeplIsPro.value = meta.deeplIsPro; + deeplFreeMode.value = meta.deeplFreeMode; + deeplFreeInstance.value = meta.deeplFreeInstance; } function save() { os.apiWithDialog('admin/update-meta', { deeplAuthKey: deeplAuthKey.value, deeplIsPro: deeplIsPro.value, + deeplFreeMode: deeplFreeMode.value, + deeplFreeInstance: deeplFreeInstance.value, }).then(() => { - fetchInstance(); + fetchInstance(true); }); } @@ -68,10 +83,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.externalServices, icon: 'ph-arrow-square-out ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 1888a0eb16..f8c4a3b272 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -105,10 +105,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.federation, icon: 'ph-globe-hemisphere-west ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 6808da6088..2a70f1c4ec 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -42,6 +42,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -79,11 +80,11 @@ function show(file) { async function find() { const { canceled, result: q } = await os.inputText({ title: i18n.ts.fileIdOrUrl, - allowEmpty: false, + minLength: 1, }); if (canceled) return; - os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { + misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { show(file); }).catch(err => { if (err.code === 'NO_SUCH_FILE') { @@ -107,8 +108,8 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.files, icon: 'ph-cloud ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 1b41a48cb4..0fd073dd0d 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -29,15 +29,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ComputedRef, Ref, onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue'; +import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js'; -import { useRouter } from '@/router.js'; -import { PageMetadata, definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { useRouter } from '@/router/supplier.js'; const isEmpty = (x: string | null) => x == null || x === ''; @@ -52,26 +53,26 @@ const indexInfo = { provide('shouldOmitHeaderTitle', false); const INFO = ref(indexInfo); -const childInfo: Ref<ComputedRef<PageMetadata> | null> = ref(null); +const childInfo = ref<null | PageMetadata>(null); const narrow = ref(false); const view = ref(null); const el = ref<HTMLDivElement | null>(null); const pageProps = ref({}); let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); -let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; +let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableMcaptcha && !instance.enableTurnstile; let noEmailServer = !instance.enableEmail; const thereIsUnresolvedAbuseReport = ref(false); const pendingUserApprovals = ref(false); const currentPage = computed(() => router.currentRef.value.child); -os.api('admin/abuse-user-reports', { +misskeyApi('admin/abuse-user-reports', { state: 'unresolved', limit: 1, }).then(reports => { if (reports.length > 0) thereIsUnresolvedAbuseReport.value = true; }); -os.api('admin/show-users', { +misskeyApi('admin/show-users', { state: 'approved', origin: 'local', limit: 1, @@ -271,17 +272,19 @@ watch(router.currentRef, (to) => { } }); -provideMetadataReceiver((info) => { +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); if (info == null) { childInfo.value = null; } else { childInfo.value = info; - INFO.value.needWideArea = info.value.needWideArea ?? undefined; + INFO.value.needWideArea = info.needWideArea ?? undefined; } }); +provideReactiveMetadata(INFO); function invite() { - os.api('admin/invite/create').then(x => { + misskeyApi('admin/invite/create').then(x => { os.alert({ type: 'info', text: x[0].code, @@ -309,7 +312,7 @@ function lookup(ev: MouseEvent) { }, }, { text: i18n.ts.note, - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', action: () => { alert('TODO'); }, @@ -332,7 +335,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(INFO.value); +definePageMetadata(() => INFO.value); defineExpose({ header: { diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue index e54f6dc065..fcb67633f6 100644 --- a/packages/frontend/src/pages/admin/instance-block.vue +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -29,6 +29,7 @@ import MkButton from '@/components/MkButton.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -38,7 +39,7 @@ const silencedHosts = ref<string>(''); const tab = ref('block'); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); blockedHosts.value = meta.blockedHosts.join('\n'); silencedHosts.value = meta.silencedHosts.join('\n'); } @@ -49,7 +50,7 @@ function save() { silencedHosts: silencedHosts.value.split('\n') || [], }).then(() => { - fetchInstance(); + fetchInstance(true); }); } @@ -65,8 +66,8 @@ const headerTabs = computed(() => [{ icon: 'ph-eye-closed ph-bold ph-lg', }]); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.instanceBlocking, icon: 'ph-prohibit ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 6314d0ce4e..7b8a1e1d4e 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -59,6 +59,7 @@ import { computed, ref, shallowRef } from 'vue'; import XHeader from './_header_.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; @@ -93,14 +94,14 @@ async function createWithOptions() { count: createCount.value, }; - const tickets = await os.api('admin/invite/create', options); + const tickets = await misskeyApi('admin/invite/create', options); os.alert({ type: 'success', title: i18n.ts.inviteCodeCreated, - text: tickets?.map(x => x.code).join('\n'), + text: tickets.map(x => x.code).join('\n'), }); - tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket)); + tickets.forEach(ticket => pagingComponent.value?.prepend(ticket)); } function deleted(id: string) { @@ -112,10 +113,10 @@ function deleted(id: string) { const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.invite, icon: 'ph-user-plus ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 9539611f76..13af28b659 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -49,6 +49,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> </MkTextarea> + <MkTextarea v-model="prohibitedWords"> + <template #label>{{ i18n.ts.prohibitedWords }}</template> + <template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template> + </MkTextarea> + <MkTextarea v-model="hiddenTags"> <template #label>{{ i18n.ts.hiddenTags }}</template> <template #caption>{{ i18n.ts.hiddenTagsDescription }}</template> @@ -75,6 +80,7 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -86,6 +92,7 @@ const emailRequiredForSignup = ref<boolean>(false); const approvalRequiredForSignup = ref<boolean>(false); const bubbleTimelineEnabled = ref<boolean>(false); const sensitiveWords = ref<string>(''); +const prohibitedWords = ref<string>(''); const hiddenTags = ref<string>(''); const preservedUsernames = ref<string>(''); const bubbleTimeline = ref<string>(''); @@ -93,11 +100,12 @@ const tosUrl = ref<string | null>(null); const privacyPolicyUrl = ref<string | null>(null); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); enableRegistration.value = !meta.disableRegistration; emailRequiredForSignup.value = meta.emailRequiredForSignup; approvalRequiredForSignup.value = meta.approvalRequiredForSignup; sensitiveWords.value = meta.sensitiveWords.join('\n'); + prohibitedWords.value = meta.prohibitedWords.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n'); preservedUsernames.value = meta.preservedUsernames.join('\n'); tosUrl.value = meta.tosUrl; @@ -114,20 +122,21 @@ function save() { tosUrl: tosUrl.value, privacyPolicyUrl: privacyPolicyUrl.value, sensitiveWords: sensitiveWords.value.split('\n'), + prohibitedWords: prohibitedWords.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'), preservedUsernames: preservedUsernames.value.split('\n'), bubbleInstances: bubbleTimeline.value.split('\n'), }).then(() => { - fetchInstance(); + fetchInstance(true); }); } const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.moderation, icon: 'ph-shield ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 322d2d531c..03d5d6ece1 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -114,6 +114,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 acb0336491..4651bb4516 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -54,14 +54,12 @@ const pagination = { })), }; -console.log(Misskey); - const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.moderationLogs, icon: 'ph-list ph-bold ph-lg-search', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index e71e53c942..4f362e1814 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -90,6 +90,7 @@ import MkInput from '@/components/MkInput.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -110,7 +111,7 @@ const objectStorageSetPublicRead = ref<boolean>(false); const objectStorageS3ForcePathStyle = ref<boolean>(true); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); useObjectStorage.value = meta.useObjectStorage; objectStorageBaseUrl.value = meta.objectStorageBaseUrl; objectStorageBucket.value = meta.objectStorageBucket; @@ -142,16 +143,16 @@ function save() { objectStorageSetPublicRead: objectStorageSetPublicRead.value, objectStorageS3ForcePathStyle: objectStorageS3ForcePathStyle.value, }).then(() => { - fetchInstance(); + fetchInstance(true); }); } const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.objectStorage, icon: 'ph-cloud ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue index 6523676a18..20e0d6e578 100644 --- a/packages/frontend/src/pages/admin/other-settings.vue +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -61,6 +61,7 @@ import { ref, computed } from 'vue'; import XHeader from './_header_.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -74,7 +75,7 @@ const enableChartsForRemoteUser = ref<boolean>(false); const enableChartsForFederatedInstances = ref<boolean>(false); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); enableServerMachineStats.value = meta.enableServerMachineStats; enableAchievements.value = meta.enableAchievements; enableBotTrending.value = meta.enableBotTrending; @@ -92,7 +93,7 @@ function save() { enableChartsForRemoteUser: enableChartsForRemoteUser.value, enableChartsForFederatedInstances: enableChartsForFederatedInstances.value, }).then(() => { - fetchInstance(); + fetchInstance(true); }); } @@ -105,8 +106,8 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.other, icon: 'ph-faders ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 5e67370c2b..79dd6fd5fd 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -52,7 +52,7 @@ async function renderChart() { })); }; - const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index 0de62fadea..d4c83f21b6 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; import { defaultStore } from '@/store.js'; @@ -65,7 +65,7 @@ onMounted(async () => { })); }; - const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const succColor = '#87e000'; diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index 2fad222bda..3a3550c6c0 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -49,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, ref } from 'vue'; import XPie, { type InstanceForPie } from './overview.pie.vue'; import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import number from '@/filters/number.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import { i18n } from '@/i18n.js'; @@ -65,13 +66,13 @@ const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); onMounted(async () => { - const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' }); + const chart = await misskeyApiGet('charts/federation', { limit: 2, span: 'day' }); federationPubActive.value = chart.pubActive[0]; federationPubActiveDiff.value = chart.pubActive[0] - chart.pubActive[1]; federationSubActive.value = chart.subActive[0]; federationSubActiveDiff.value = chart.subActive[0] - chart.subActive[1]; - os.apiGet('federation/stats', { limit: 10 }).then(res => { + misskeyApiGet('federation/stats', { limit: 10 }).then(res => { topSubInstancesForPie.value = [ ...res.topSubInstances.map(x => ({ name: x.host, diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue index 8e3c809353..7b2b142b16 100644 --- a/packages/frontend/src/pages/admin/overview.heatmap.vue +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index de34f0c09b..a09db2a6d5 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import { defaultStore } from '@/store.js'; @@ -28,7 +28,7 @@ const instances = ref<Misskey.entities.FederationInstance[]>([]); const fetching = ref(true); const fetch = async () => { - const fetchedInstances = await os.api('federation/instances', { + const fetchedInstances = await misskeyApi('federation/instances', { sort: '+latestRequestReceivedAt', limit: 6, }); diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue index 3034bdd57e..f0691534c8 100644 --- a/packages/frontend/src/pages/admin/overview.moderators.vue +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,15 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; const moderators = ref<Misskey.entities.UserDetailed[] | null>(null); const fetching = ref(true); onMounted(async () => { - moderators.value = await os.api('admin/show-users', { + moderators.value = await misskeyApi('admin/show-users', { sort: '+lastActiveDate', state: 'adminOrModerator', limit: 30, diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index 95c1f57b29..c7a9f2a702 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 38309e351a..2efc17c888 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index b6b3bf194a..c7478f252a 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue index 514db663ab..adcb9d5948 100644 --- a/packages/frontend/src/pages/admin/overview.retention.vue +++ b/packages/frontend/src/pages/admin/overview.retention.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index adbfe3f9e2..27ae52c32c 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div class="item _panel notes"> - <div class="icon"><i class="ph-pencil ph-bold ph-lg"></i></div> + <div class="icon"><i class="ph-pencil-simple ph-bold ph-lg"></i></div> <div class="body"> <div class="value"> <MkNumber :value="stats.originalNotesCount" style="margin-right: 0.5em;"/> @@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import MkNumber from '@/components/MkNumber.vue'; import { i18n } from '@/i18n.js'; @@ -78,17 +78,17 @@ const fetching = ref(true); onMounted(async () => { const [_stats, _onlineUsersCount] = await Promise.all([ - os.api('stats', {}), - os.apiGet('get-online-users-count').then(res => res.count), + misskeyApi('stats', {}), + misskeyApiGet('get-online-users-count').then(res => res.count), ]); stats.value = _stats; onlineUsersCount.value = _onlineUsersCount; - os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { + misskeyApiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { usersComparedToThePrevDay.value = stats.value.originalUsersCount - chart.local.total[1]; }); - os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { + misskeyApiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { notesComparedToThePrevDay.value = stats.value.originalNotesCount - chart.local.total[1]; }); diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue index 79579367c1..408be88d47 100644 --- a/packages/frontend/src/pages/admin/overview.users.vue +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { defaultStore } from '@/store.js'; @@ -28,7 +28,7 @@ const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null); const fetching = ref(true); const fetch = async () => { - const _newUsers = await os.api('admin/show-users', { + const _newUsers = await misskeyApi('admin/show-users', { limit: 5, sort: '+createdAt', origin: 'local', diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 9f2920ee0c..bf766600e5 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -79,6 +79,7 @@ import XModerators from './overview.moderators.vue'; import XHeatmap from './overview.heatmap.vue'; import type { InstanceForPie } from './overview.pie.vue'; import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -117,14 +118,14 @@ onMounted(async () => { magicGrid.listen(); */ - os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { + misskeyApiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { federationPubActive.value = chart.pubActive[0]; federationPubActiveDiff.value = chart.pubActive[0] - chart.pubActive[1]; federationSubActive.value = chart.subActive[0]; federationSubActiveDiff.value = chart.subActive[0] - chart.subActive[1]; }); - os.apiGet('federation/stats', { limit: 10 }).then(res => { + misskeyApiGet('federation/stats', { limit: 10 }).then(res => { topSubInstancesForPie.value = [ ...res.topSubInstances.map(x => ({ name: x.host, @@ -149,18 +150,18 @@ onMounted(async () => { ]; }); - os.api('admin/server-info').then(serverInfoResponse => { + misskeyApi('admin/server-info').then(serverInfoResponse => { serverInfo.value = serverInfoResponse; }); - os.api('admin/show-users', { + misskeyApi('admin/show-users', { limit: 5, sort: '+createdAt', }).then(res => { newUsers.value = res; }); - os.api('federation/instances', { + misskeyApi('federation/instances', { sort: '+latestRequestReceivedAt', limit: 25, }).then(res => { @@ -183,10 +184,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.dashboard, icon: 'ph-gauge ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue index 1425749bd4..59fd5911d4 100644 --- a/packages/frontend/src/pages/admin/proxy-account.vue +++ b/packages/frontend/src/pages/admin/proxy-account.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -28,6 +28,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -36,15 +37,15 @@ const proxyAccount = ref<Misskey.entities.UserDetailed | null>(null); const proxyAccountId = ref<string | null>(null); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); proxyAccountId.value = meta.proxyAccountId; if (proxyAccountId.value) { - proxyAccount.value = await os.api('users/show', { userId: proxyAccountId.value }); + proxyAccount.value = await misskeyApi('users/show', { userId: proxyAccountId.value }); } } function chooseProxyAccount() { - os.selectUser().then(user => { + os.selectUser({ localOnly: true }).then(user => { proxyAccount.value = user; proxyAccountId.value = user.id; save(); @@ -55,7 +56,7 @@ function save() { os.apiWithDialog('admin/update-meta', { proxyAccountId: proxyAccountId.value, }).then(() => { - fetchInstance(); + fetchInstance(true); }); } @@ -63,8 +64,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.proxyAccount, icon: 'ph-ghost ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue index 566670c843..cc18898172 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue index b829dd5738..f7b4b27a68 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; import XChart from './queue.chart.chart.vue'; import number from '@/filters/number.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; @@ -105,7 +105,7 @@ const onStatsLog = (statsLog) => { onMounted(() => { if (props.domain === 'inbox' || props.domain === 'deliver') { - os.api(`admin/queue/${props.domain}-delayed`).then(result => { + misskeyApi(`admin/queue/${props.domain}-delayed`).then(result => { jobs.value = result; }); } diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue index 245c55f6e7..ba6911a943 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/queue.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -68,8 +68,8 @@ const headerTabs = computed(() => [{ title: 'Inbox', }]); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.jobQueue, icon: 'ph-clock ph-bold ph-lg-play', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index 578c29ee6c..6ff0d8bd22 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="relay.status === 'accepted'" class="ph-check ph-bold ph-lg" :class="$style.icon" style="color: var(--success);"></i> <i v-else-if="relay.status === 'rejected'" class="ph-prohibit ph-bold ph-lg" :class="$style.icon" style="color: var(--error);"></i> <i v-else class="ph-clock ph-bold ph-lg" :class="$style.icon"></i> - <span>{{ i18n.t(`_relayStatus.${relay.status}`) }}</span> + <span>{{ i18n.ts._relayStatus[relay.status] }}</span> </div> <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.remove }}</MkButton> </div> @@ -29,6 +29,7 @@ import * as Misskey from 'misskey-js'; import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -41,7 +42,7 @@ async function addRelay() { placeholder: i18n.ts.inboxUrl, }); if (canceled) return; - os.api('admin/relays/add', { + misskeyApi('admin/relays/add', { inbox, }).then((relay: any) => { refresh(); @@ -54,7 +55,7 @@ async function addRelay() { } function remove(inbox: string) { - os.api('admin/relays/remove', { + misskeyApi('admin/relays/remove', { inbox, }).then(() => { refresh(); @@ -67,7 +68,7 @@ function remove(inbox: string) { } function refresh() { - os.api('admin/relays/list').then(relayList => { + misskeyApi('admin/relays/list').then(relayList => { relays.value = relayList; }); } @@ -83,10 +84,10 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.relays, icon: 'ph-planet ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index 980c311156..e6023d2f2a 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -28,11 +28,12 @@ import { v4 as uuid } from 'uuid'; import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; import { rolesCache } from '@/cache.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -44,7 +45,7 @@ const role = ref<Misskey.entities.Role | null>(null); const data = ref<any>(null); if (props.id) { - role.value = await os.api('admin/roles/show', { + role.value = await misskeyApi('admin/roles/show', { roleId: props.id, }); @@ -86,11 +87,8 @@ async function save() { const headerTabs = computed(() => []); -definePageMetadata(computed(() => role.value ? { - title: i18n.ts._role.edit + ': ' + role.value.name, - icon: 'ph-seal-check ph-bold ph-lg', -} : { - title: i18n.ts._role.new, +definePageMetadata(() => ({ + title: role.value ? `${i18n.ts._role.edit}: ${role.value.name}` : i18n.ts._role.new, icon: 'ph-seal-check ph-bold ph-lg', })); </script> diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 164510fd24..99a31d5157 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -200,6 +200,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> + <template #label>{{ i18n.ts._role._options.mentionMax }}</template> + <template #suffix> + <span v-if="role.policies.mentionLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.mentionLimit.value }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.mentionLimit)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.mentionLimit.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.mentionLimit.value" :disabled="role.policies.mentionLimit.useDefault" type="number" :readonly="readonly"> + </MkInput> + <MkRange v-model="role.policies.mentionLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])"> <template #label>{{ i18n.ts._role._options.canInvite }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 92818cc3de..cda524f787 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="700"> <div class="_gaps"> <div class="_buttons"> - <MkButton primary rounded @click="edit"><i class="ph-pencil ph-bold ph-lg"></i> {{ i18n.ts.edit }}</MkButton> + <MkButton primary rounded @click="edit"><i class="ph-pencil-simple ph-bold ph-lg"></i> {{ i18n.ts.edit }}</MkButton> <MkButton danger rounded @click="del"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton> </div> <MkFolder> @@ -67,14 +67,15 @@ import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { infoImageUrl } from '@/instance.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -92,7 +93,7 @@ const usersPagination = { const expandedItems = ref([]); -const role = reactive(await os.api('admin/roles/show', { +const role = reactive(await misskeyApi('admin/roles/show', { roleId: props.id, })); @@ -103,7 +104,7 @@ function edit() { async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: role.name }), + text: i18n.tsx.deleteAreYouSure({ x: role.name }), }); if (canceled) return; @@ -115,9 +116,7 @@ async function del() { } async function assign() { - const user = await os.selectUser({ - includeSelf: true, - }); + const user = await os.selectUser({ includeSelf: true }); const { canceled: canceled2, result: period } = await os.select({ title: i18n.ts.period, @@ -171,10 +170,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ - title: i18n.ts.role + ': ' + role.name, +definePageMetadata(() => ({ + title: `${i18n.ts.role}: ${role.name}`, icon: 'ph-seal-check ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 9cb48130d8..f104f9a00d 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -67,6 +67,13 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> + <template #label>{{ i18n.ts._role._options.mentionMax }}</template> + <template #suffix>{{ policies.mentionLimit }}</template> + <MkInput v-model="policies.mentionLimit" type="number"> + </MkInput> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])"> <template #label>{{ i18n.ts._role._options.canInvite }}</template> <template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template> @@ -253,17 +260,18 @@ import MkRange from '@/components/MkRange.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { instance } from '@/instance.js'; -import { useRouter } from '@/router.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { ROLE_POLICIES } from '@/const.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); const baseRoleQ = ref(''); -const roles = await os.api('admin/roles/list'); +const roles = await misskeyApi('admin/roles/list'); const policies = reactive<Record<typeof ROLE_POLICIES[number], any>>({}); for (const ROLE_POLICY of ROLE_POLICIES) { @@ -289,10 +297,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.roles, icon: 'ph-seal-check ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 8ed3e20af3..8e75975209 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ph-shield ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.botProtection }}</template> <template v-if="enableHcaptcha" #suffix>hCaptcha</template> + <template v-else-if="enableMcaptcha" #suffix>mCaptcha</template> <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> <template v-else-if="enableTurnstile" #suffix>Turnstile</template> <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> @@ -27,16 +28,28 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <span>{{ i18n.ts.activeEmailValidationDescription }}</span> - <MkSwitch v-model="enableActiveEmailValidation" @update:modelValue="save"> + <MkSwitch v-model="enableActiveEmailValidation"> <template #label>Enable</template> </MkSwitch> - <MkSwitch v-model="enableVerifymailApi" @update:modelValue="save"> + <MkSwitch v-model="enableVerifymailApi"> <template #label>Use Verifymail.io API</template> </MkSwitch> - <MkInput v-model="verifymailAuthKey" @update:modelValue="save"> + <MkInput v-model="verifymailAuthKey"> <template #prefix><i class="ph-key ph-bold ph-lg"></i></template> <template #label>Verifymail.io API Auth Key</template> </MkInput> + <MkSwitch v-model="enableTruemailApi"> + <template #label>Use TrueMail API</template> + </MkSwitch> + <MkInput v-model="truemailInstance"> + <template #prefix><i class="ph-key ph-bold ph-lg"></i></template> + <template #label>TrueMail API Instance</template> + </MkInput> + <MkInput v-model="truemailAuthKey"> + <template #prefix><i class="ph-key ph-bold ph-lg"></i></template> + <template #label>TrueMail API Auth Key</template> + </MkInput> + <MkButton primary @click="save"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> </div> </MkFolder> @@ -94,31 +107,40 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; const summalyProxy = ref<string>(''); const enableHcaptcha = ref<boolean>(false); +const enableMcaptcha = ref<boolean>(false); const enableRecaptcha = ref<boolean>(false); const enableTurnstile = ref<boolean>(false); const enableIpLogging = ref<boolean>(false); const enableActiveEmailValidation = ref<boolean>(false); const enableVerifymailApi = ref<boolean>(false); const verifymailAuthKey = ref<string | null>(null); +const enableTruemailApi = ref<boolean>(false); +const truemailInstance = ref<string | null>(null); +const truemailAuthKey = ref<string | null>(null); const bannedEmailDomains = ref<string>(''); async function init() { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); summalyProxy.value = meta.summalyProxy; enableHcaptcha.value = meta.enableHcaptcha; + enableMcaptcha.value = meta.enableMcaptcha; enableRecaptcha.value = meta.enableRecaptcha; enableTurnstile.value = meta.enableTurnstile; enableIpLogging.value = meta.enableIpLogging; enableActiveEmailValidation.value = meta.enableActiveEmailValidation; enableVerifymailApi.value = meta.enableVerifymailApi; verifymailAuthKey.value = meta.verifymailAuthKey; - bannedEmailDomains.value = meta.bannedEmailDomains.join('\n'); + enableTruemailApi.value = meta.enableTruemailApi; + truemailInstance.value = meta.truemailInstance; + truemailAuthKey.value = meta.truemailAuthKey; + bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || ''; } function save() { @@ -128,9 +150,12 @@ function save() { enableActiveEmailValidation: enableActiveEmailValidation.value, enableVerifymailApi: enableVerifymailApi.value, verifymailAuthKey: verifymailAuthKey.value, + enableTruemailApi: enableTruemailApi.value, + truemailInstance: truemailInstance.value, + truemailAuthKey: truemailAuthKey.value, bannedEmailDomains: bannedEmailDomains.value.split('\n'), }).then(() => { - fetchInstance(); + fetchInstance(true); }); } @@ -138,8 +163,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.security, icon: 'ph-lock ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index 6aecb43399..db2bb56eac 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -58,7 +58,7 @@ const save = async () => { await os.apiWithDialog('admin/update-meta', { serverRules: serverRules.value, }); - fetchInstance(); + fetchInstance(true); }; const remove = (index: number): void => { @@ -67,10 +67,10 @@ const remove = (index: number): void => { const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.serverRules, - icon: 'ph-check ph-bold ph-lgbox', -}); + icon: 'ph-check ph-bold ph-lg', +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 649f22e644..887ac6fb4c 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -34,12 +34,27 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </FormSplit> + <MkInput v-model="repositoryUrl" type="url"> + <template #label>{{ i18n.ts.repositoryUrl }}</template> + <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> + <template #caption>{{ i18n.ts.repositoryUrlDescription }}</template> + </MkInput> + + <MkInfo v-if="!instance.providesTarball && !repositoryUrl" warn> + {{ i18n.ts.repositoryUrlOrTarballRequired }} + </MkInfo> + <MkInput v-model="impressumUrl" type="url"> <template #label>{{ i18n.ts.impressumUrl }}</template> <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> <template #caption>{{ i18n.ts.impressumDescription }}</template> </MkInput> + <MkInput v-model="donationUrl" type="url"> + <template #label>{{ i18n.ts.donationUrl }}</template> + <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> + </MkInput> + <MkTextarea v-model="pinnedUsers"> <template #label>{{ i18n.ts.pinnedUsers }}</template> <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> @@ -158,7 +173,8 @@ import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { fetchInstance } from '@/instance.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; @@ -168,7 +184,9 @@ const shortName = ref<string | null>(null); const description = ref<string | null>(null); const maintainerName = ref<string | null>(null); const maintainerEmail = ref<string | null>(null); +const repositoryUrl = ref<string | null>(null); const impressumUrl = ref<string | null>(null); +const donationUrl = ref<string | null>(null); const pinnedUsers = ref<string>(''); const cacheRemoteFiles = ref<boolean>(false); const cacheRemoteSensitiveFiles = ref<boolean>(false); @@ -184,13 +202,15 @@ const perUserListTimelineCacheMax = ref<number>(0); const notesPerOneAd = ref<number>(0); async function init(): Promise<void> { - const meta = await os.api('admin/meta'); + const meta = await misskeyApi('admin/meta'); name.value = meta.name; shortName.value = meta.shortName; description.value = meta.description; maintainerName.value = meta.maintainerName; maintainerEmail.value = meta.maintainerEmail; + repositoryUrl.value = meta.repositoryUrl; impressumUrl.value = meta.impressumUrl; + donationUrl.value = meta.donationUrl; pinnedUsers.value = meta.pinnedUsers.join('\n'); cacheRemoteFiles.value = meta.cacheRemoteFiles; cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles; @@ -213,7 +233,9 @@ async function save(): void { description: description.value, maintainerName: maintainerName.value, maintainerEmail: maintainerEmail.value, + repositoryUrl: repositoryUrl.value, impressumUrl: impressumUrl.value, + donationUrl: donationUrl.value, pinnedUsers: pinnedUsers.value.split('\n'), cacheRemoteFiles: cacheRemoteFiles.value, cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value, @@ -229,15 +251,15 @@ async function save(): void { notesPerOneAd: notesPerOneAd.value, }); - fetchInstance(); + fetchInstance(true); } const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.general, icon: 'ph-gear ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 1bc4eb4089..626346a998 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -91,7 +91,7 @@ const pagination = { }; function searchUser() { - os.selectUser().then(user => { + os.selectUser({ includeSelf: true }).then(user => { show(user); }); } @@ -138,10 +138,10 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.users, icon: 'ph-users ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue index 9d508937af..c6373e8d60 100644 --- a/packages/frontend/src/pages/ads.vue +++ b/packages/frontend/src/pages/ads.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,9 +20,9 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.ads, icon: 'ph-flag ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 705115abb0..4f5abdb385 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,34 +7,36 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="800"> - <div class="_gaps"> - <MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> - <MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> - <section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> - <div v-if="announcement.forYou" :class="$style.forYou"><i class="ph-push-pin ph-bold ph-lg"></i> {{ i18n.ts.forYou }}</div> - <div :class="$style.header"> - <span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> - <span style="margin-right: 0.5em;"> - <i v-if="announcement.icon === 'info'" class="ph-info ph-bold ph-lg"></i> - <i v-else-if="announcement.icon === 'warning'" class="ph-warning ph-bold ph-lg" style="color: var(--warn);"></i> - <i v-else-if="announcement.icon === 'error'" class="ph-x-circle ph-bold ph-lg" style="color: var(--error);"></i> - <i v-else-if="announcement.icon === 'success'" class="ph-check ph-bold ph-lg" style="color: var(--success);"></i> - </span> - <span>{{ announcement.title }}</span> - </div> - <div :class="$style.content"> - <Mfm :text="announcement.text"/> - <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> - <div style="opacity: 0.7; font-size: 85%;"> - <MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div :key="tab" class="_gaps"> + <MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> + <MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> + <section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> + <div v-if="announcement.forYou" :class="$style.forYou"><i class="ph-push-pin ph-bold ph-lg"></i> {{ i18n.ts.forYou }}</div> + <div :class="$style.header"> + <span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> + <span style="margin-right: 0.5em;"> + <i v-if="announcement.icon === 'info'" class="ph-info ph-bold ph-lg"></i> + <i v-else-if="announcement.icon === 'warning'" class="ph-warning ph-bold ph-lg" style="color: var(--warn);"></i> + <i v-else-if="announcement.icon === 'error'" class="ph-x-circle ph-bold ph-lg" style="color: var(--error);"></i> + <i v-else-if="announcement.icon === 'success'" class="ph-check ph-bold ph-lg" style="color: var(--success);"></i> + </span> + <span>{{ announcement.title }}</span> </div> - </div> - <div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> - <MkButton primary @click="read(announcement)"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.gotIt }}</MkButton> - </div> - </section> - </MkPagination> - </div> + <div :class="$style.content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + <div style="opacity: 0.7; font-size: 85%;"> + <MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> + </div> + </div> + <div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> + <MkButton primary @click="read(announcement)"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.gotIt }}</MkButton> + </div> + </section> + </MkPagination> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -44,7 +46,9 @@ import { ref, computed } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i, updateAccount } from '@/account.js'; @@ -74,7 +78,7 @@ async function read(announcement) { const confirm = await os.confirm({ type: 'question', title: i18n.ts._announcement.readConfirmTitle, - text: i18n.t('_announcement.readConfirmText', { title: announcement.title }), + text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }), }); if (confirm.canceled) return; } @@ -84,7 +88,7 @@ async function read(announcement) { a.isRead = true; return a; }); - os.api('i/read-announcement', { announcementId: announcement.id }); + misskeyApi('i/read-announcement', { announcementId: announcement.id }); updateAccount({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), }); @@ -102,10 +106,10 @@ const headerTabs = computed(() => [{ icon: 'ph-circle ph-bold ph-lg', }]); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.announcements, icon: 'ph-megaphone ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 9abf0b9776..3e8deff711 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -29,9 +29,10 @@ import * as Misskey from 'misskey-js'; import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -73,7 +74,7 @@ function focus() { } watch(() => props.antennaId, async () => { - antenna.value = await os.api('antennas/show', { + antenna.value = await misskeyApi('antennas/show', { antennaId: props.antennaId, }); }, { immediate: true }); @@ -90,10 +91,10 @@ const headerActions = computed(() => antenna.value ? [{ const headerTabs = computed(() => []); -definePageMetadata(computed(() => antenna.value ? { - title: antenna.value.name, +definePageMetadata(() => ({ + title: antenna.value ? antenna.value.name : i18n.ts.antennas, icon: 'ph-flying-saucer ph-bold ph-lg', -} : null)); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue index dcdb5b8fe3..4d0cb2897f 100644 --- a/packages/frontend/src/pages/api-console.vue +++ b/packages/frontend/src/pages/api-console.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,7 +41,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; const body = ref('{}'); @@ -51,14 +51,14 @@ const sending = ref(false); const res = ref(''); const withCredential = ref(true); -os.api('endpoints').then(endpointResponse => { +misskeyApi('endpoints').then(endpointResponse => { endpoints.value = endpointResponse; }); function send() { sending.value = true; const requestBody = JSON5.parse(body.value); - os.api(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => { + misskeyApi(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => { sending.value = false; res.value = JSON5.stringify(resp, null, 2); }, err => { @@ -68,7 +68,7 @@ function send() { } function onEndpointChange() { - os.api('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => { + misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => { const endpointBody = {}; for (const p of resp.params) { endpointBody[p.name] = @@ -87,8 +87,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: 'API console', icon: 'ph-terminal-window ph-bold ph-lg-2', -}); +})); </script> diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue index 8a17e5895d..f4fb2ef4d5 100644 --- a/packages/frontend/src/pages/auth.form.vue +++ b/packages/frontend/src/pages/auth.form.vue @@ -1,17 +1,17 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <section> <div v-if="app.permission.length > 0"> - <p>{{ i18n.t('_auth.permission', { name }) }}</p> + <p>{{ i18n.tsx._auth.permission({ name }) }}</p> <ul> - <li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> + <li v-for="p in app.permission" :key="p">{{ i18n.ts._permissions[p] }}</li> </ul> </div> - <div>{{ i18n.t('_auth.shareAccess', { name: `${name} (${app.id})` }) }}</div> + <div>{{ i18n.tsx._auth.shareAccess({ name: `${name} (${app.id})` }) }}</div> <div :class="$style.buttons"> <MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -44,7 +44,7 @@ const name = computed(() => { }); function cancel() { - os.api('auth/deny', { + misskeyApi('auth/deny', { token: props.session.token, }).then(() => { emit('denied'); @@ -52,7 +52,7 @@ function cancel() { } function accept() { - os.api('auth/accept', { + misskeyApi('auth/accept', { token: props.session.token, }).then(() => { emit('accepted'); diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index d97e89842d..cb735a26d8 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <h1>{{ i18n.ts._auth.denied }}</h1> </div> <div v-if="state == 'accepted' && session"> - <h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1> + <h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts.allowed }}</h1> <p v-if="session.app.callbackUrl"> {{ i18n.ts._auth.callback }} <MkEllipsis/> @@ -46,7 +46,7 @@ import { onMounted, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import XForm from './auth.form.vue'; import MkSignin from '@/components/MkSignin.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i, login } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; @@ -96,13 +96,13 @@ onMounted(async () => { if (!$i) return; try { - session.value = await os.api('auth/session/show', { + session.value = await misskeyApi('auth/session/show', { token: props.token, }); // 既に連携していた場合 if (session.value.app.isAuthorized) { - await os.api('auth/accept', { + await misskeyApi('auth/accept', { token: session.value.token, }); accepted(); @@ -118,10 +118,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts._auth.shareAccessTitle, icon: 'ph-squares-four ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue index 30b100a7fb..41c87d3625 100644 --- a/packages/frontend/src/pages/avatar-decorations.vue +++ b/packages/frontend/src/pages/avatar-decorations.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -40,6 +40,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkFolder from '@/components/MkFolder.vue'; @@ -59,11 +60,11 @@ function add() { function del(avatarDecoration) { os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: avatarDecoration.name }), + text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }), }).then(({ canceled }) => { if (canceled) return; avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration); - os.api('admin/avatar-decorations/delete', avatarDecoration); + misskeyApi('admin/avatar-decorations/delete', avatarDecoration); }); } @@ -77,7 +78,7 @@ async function save(avatarDecoration) { } function load() { - os.api('admin/avatar-decorations/list').then(_avatarDecorations => { + misskeyApi('admin/avatar-decorations/list').then(_avatarDecorations => { avatarDecorations.value = _avatarDecorations; }); } @@ -93,8 +94,8 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.avatarDecorations, icon: 'ph-sparkle ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 912d02c7fc..ea4374dd3c 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -76,12 +76,13 @@ import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; import { selectFile } from '@/scripts/select-file.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +import { useRouter } from '@/router/supplier.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -105,7 +106,7 @@ watch(() => bannerId.value, async () => { if (bannerId.value == null) { bannerUrl.value = null; } else { - bannerUrl.value = (await os.api('drive/files/show', { + bannerUrl.value = (await misskeyApi('drive/files/show', { fileId: bannerId.value, })).url; } @@ -114,7 +115,7 @@ watch(() => bannerId.value, async () => { async function fetchChannel() { if (props.channelId == null) return; - channel.value = await os.api('channels/show', { + channel.value = await misskeyApi('channels/show', { channelId: props.channelId, }); @@ -173,13 +174,13 @@ function save() { async function archive() { const { canceled } = await os.confirm({ type: 'warning', - title: i18n.t('channelArchiveConfirmTitle', { name: name.value }), + title: i18n.tsx.channelArchiveConfirmTitle({ name: name.value }), text: i18n.ts.channelArchiveConfirmDescription, }); if (canceled) return; - os.api('channels/update', { + misskeyApi('channels/update', { channelId: props.channelId, isArchived: true, }).then(() => { @@ -201,11 +202,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => props.channelId ? { - title: i18n.ts._channel.edit, - icon: 'ph-television ph-bold ph-lg', -} : { - title: i18n.ts._channel.create, +definePageMetadata(() => ({ + title: props.channelId ? i18n.ts._channel.edit : i18n.ts._channel.create, icon: 'ph-television ph-bold ph-lg', })); </script> diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index b0873ea336..881acd0197 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,59 +7,61 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700" :class="$style.main"> - <div v-if="channel && tab === 'overview'" class="_gaps"> - <div class="_panel" :class="$style.bannerContainer"> - <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> - <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ph-star ph-bold ph-lg"></i></MkButton> - <MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ph-star ph-bold ph-lg"></i></MkButton> - <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner"> - <div :class="$style.bannerStatus"> - <div><i class="ph-users ph-bold ph-lg ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> - <div><i class="ph-pencil ph-bold ph-lg ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="channel && tab === 'overview'" key="overview" class="_gaps"> + <div class="_panel" :class="$style.bannerContainer"> + <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ph-star ph-bold ph-lg"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ph-star ph-bold ph-lg"></i></MkButton> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner"> + <div :class="$style.bannerStatus"> + <div><i class="ph-users ph-bold ph-lg"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> + <div><i class="ph-pencil-simple ph-bold ph-lg"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> + </div> + <div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div> + <div :class="$style.bannerFade"></div> + </div> + <div v-if="channel.description" :class="$style.description"> + <Mfm :text="channel.description" :isNote="false"/> </div> - <div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div> - <div :class="$style.bannerFade"></div> - </div> - <div v-if="channel.description" :class="$style.description"> - <Mfm :text="channel.description" :isNote="false"/> </div> - </div> - <MkFoldableSection> - <template #header><i class="ph-push-pin ph-bold ph-lg ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> - <div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps"> - <MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/> - </div> - </MkFoldableSection> - </div> - <div v-if="channel && tab === 'timeline'" class="_gaps"> - <MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo> + <MkFoldableSection> + <template #header><i class="ph-push-pin ph-bold ph-lg" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> + <div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps"> + <MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/> + </div> + </MkFoldableSection> + </div> + <div v-if="channel && tab === 'timeline'" key="timeline" class="_gaps"> + <MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo> - <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> - <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> + <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> + <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> - <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> - </div> - <div v-else-if="tab === 'featured'"> - <MkNotes :pagination="featuredPagination"/> - </div> - <div v-else-if="tab === 'search'"> - <div class="_gaps"> - <div> - <MkInput v-model="searchQuery" @enter="search()"> - <template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template> - </MkInput> - <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton> + <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> + </div> + <div v-else-if="tab === 'featured'" key="featured"> + <MkNotes :pagination="featuredPagination"/> + </div> + <div v-else-if="tab === 'search'" key="search"> + <div class="_gaps"> + <div> + <MkInput v-model="searchQuery" @enter="search()"> + <template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template> + </MkInput> + <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton> + </div> + <MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> </div> - <MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> </div> - </div> + </MkHorizontalSwipe> </MkSpacer> <template #footer> <div :class="$style.footer"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> <div class="_buttonsCenter"> - <MkButton inline rounded primary gradate @click="openPostForm()"><i class="ph-pencil ph-bold ph-lg"></i> {{ i18n.ts.postToTheChannel }}</MkButton> + <MkButton inline rounded primary gradate @click="openPostForm()"><i class="ph-pencil-simple ph-bold ph-lg"></i> {{ i18n.ts.postToTheChannel }}</MkButton> </div> </MkSpacer> </div> @@ -74,7 +76,7 @@ import MkPostForm from '@/components/MkPostForm.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i, iAmModerator } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -87,10 +89,12 @@ import { defaultStore } from '@/store.js'; import MkNote from '@/components/MkNote.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { PageHeaderItem } from '@/types/page-header.js'; import { isSupportShare } from '@/scripts/navigator.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { miLocalStorage } from '@/local-storage.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -99,6 +103,7 @@ const props = defineProps<{ }>(); const tab = ref('overview'); + const channel = ref<Misskey.entities.Channel | null>(null); const favorited = ref(false); const searchQuery = ref(''); @@ -113,7 +118,7 @@ const featuredPagination = computed(() => ({ })); watch(() => props.channelId, async () => { - channel.value = await os.api('channels/show', { + channel.value = await misskeyApi('channels/show', { channelId: props.channelId, }); favorited.value = channel.value.isFavorited ?? false; @@ -253,10 +258,10 @@ const headerTabs = computed(() => [{ icon: 'ph-magnifying-glass ph-bold ph-lg', }]); -definePageMetadata(computed(() => channel.value ? { - title: channel.value.name, +definePageMetadata(() => ({ + title: channel.value ? channel.value.name : i18n.ts.channel, icon: 'ph-television ph-bold ph-lg', -} : null)); +})); </script> <style lang="scss" module> @@ -267,6 +272,7 @@ definePageMetadata(computed(() => channel.value ? { .footer { -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); + background: var(--acrylicBg); border-top: solid 0.5px var(--divider); } diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 63d1e454a2..253b272d2a 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,44 +7,46 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> - <div v-if="tab === 'search'"> - <div class="_gaps"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> - <template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template> - </MkInput> - <MkRadios v-model="searchType" @update:modelValue="search()"> - <option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option> - <option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option> - </MkRadios> - <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> - </div> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'search'" key="search"> + <div class="_gaps"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> + <template #prefix><i class="ph-magnifying-glass ph-bold ph-lg"></i></template> + </MkInput> + <MkRadios v-model="searchType" @update:modelValue="search()"> + <option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option> + <option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option> + </MkRadios> + <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> + </div> - <MkFoldableSection v-if="channelPagination"> - <template #header>{{ i18n.ts.searchResult }}</template> - <MkChannelList :key="key" :pagination="channelPagination"/> - </MkFoldableSection> - </div> - <div v-if="tab === 'featured'"> - <MkPagination v-slot="{items}" :pagination="featuredPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> - </MkPagination> - </div> - <div v-else-if="tab === 'favorites'"> - <MkPagination v-slot="{items}" :pagination="favoritesPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> - </MkPagination> - </div> - <div v-else-if="tab === 'following'"> - <MkPagination v-slot="{items}" :pagination="followingPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> - </MkPagination> - </div> - <div v-else-if="tab === 'owned'"> - <MkButton class="new" @click="create()"><i class="ph-plus ph-bold ph-lg"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="ownedPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> - </MkPagination> - </div> + <MkFoldableSection v-if="channelPagination"> + <template #header>{{ i18n.ts.searchResult }}</template> + <MkChannelList :key="key" :pagination="channelPagination"/> + </MkFoldableSection> + </div> + <div v-if="tab === 'featured'" key="featured"> + <MkPagination v-slot="{items}" :pagination="featuredPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'favorites'" key="favorites"> + <MkPagination v-slot="{items}" :pagination="favoritesPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'following'" key="following"> + <MkPagination v-slot="{items}" :pagination="followingPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'owned'" key="owned"> + <MkButton class="new" @click="create()"><i class="ph-plus ph-bold ph-lg"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="ownedPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + </MkPagination> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -58,9 +60,10 @@ import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { useRouter } from '@/router.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -146,11 +149,11 @@ const headerTabs = computed(() => [{ }, { key: 'owned', title: i18n.ts._channel.owned, - icon: 'ph-pencil-line ph-bold ph-lg', + icon: 'ph-pencil-simple-line ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.channel, icon: 'ph-television ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index 8c1322d732..679fb67d25 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,10 +16,10 @@ SPDX-License-Identifier: AGPL-3.0-only import MkClickerGame from '@/components/MkClickerGame.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -definePageMetadata({ +definePageMetadata(() => ({ title: '🍪👈', icon: 'ph-cookie ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 9b5f0224cc..5c646889fd 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -32,6 +32,7 @@ import MkNotes from '@/components/MkNotes.vue'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { url } from '@/config.js'; import MkButton from '@/components/MkButton.vue'; @@ -56,7 +57,7 @@ const pagination = { const isOwned = computed<boolean | null>(() => $i && clip.value && ($i.id === clip.value.userId)); watch(() => props.clipId, async () => { - clip.value = await os.api('clips/show', { + clip.value = await misskeyApi('clips/show', { clipId: props.clipId, }); favorited.value = clip.value.isFavorited; @@ -88,7 +89,7 @@ async function unfavorite() { } const headerActions = computed(() => clip.value && isOwned.value ? [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.edit, handler: async (): Promise<void> => { const { canceled, result } = await os.form(clip.value.name, { @@ -144,7 +145,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ handler: async (): Promise<void> => { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: clip.value.name }), + text: i18n.tsx.deleteAreYouSure({ x: clip.value.name }), }); if (canceled) return; @@ -156,10 +157,10 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ }, }] : null); -definePageMetadata(computed(() => clip.value ? { - title: clip.value.name, +definePageMetadata(() => ({ + title: clip.value ? clip.value.name : i18n.ts.clip, icon: 'ph-paperclip ph-bold ph-lg', -} : null)); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index bc2a268f34..1a745d6626 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -82,6 +82,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; import { selectFile } from '@/scripts/select-file.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -187,7 +188,7 @@ const menu = (ev: MouseEvent) => { icon: 'ph-download ph-bold ph-lg', text: i18n.ts.export, action: async () => { - os.api('export-custom-emojis', { + misskeyApi('export-custom-emojis', { }) .then(() => { os.alert({ @@ -206,7 +207,7 @@ const menu = (ev: MouseEvent) => { text: i18n.ts.import, action: async () => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('admin/emoji/import-zip', { + misskeyApi('admin/emoji/import-zip', { fileId: file.id, }) .then(() => { @@ -314,10 +315,10 @@ const headerTabs = computed(() => [{ title: i18n.ts.remote, }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.customEmojis, icon: 'ph-smiley ph-bold ph-lg', -}))); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 69309b37f3..872dd4d5cf 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.fileQuickActionsRoot"> <button class="_button" :class="$style.fileNameEditBtn" @click="rename()"> <h2 class="_nowrap" :class="$style.fileName">{{ file.name }}</h2> - <i class="ph-pencil ph-bold ph-lg" :class="$style.fileNameEditIcon"></i> + <i class="ph-pencil-simple ph-bold ph-lg" :class="$style.fileNameEditIcon"></i> </button> <div :class="$style.fileQuickActionsOthers"> <button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()"> - <i class="ph-pencil ph-bold ph-lg"></i> + <i class="ph-pencil-simple ph-bold ph-lg"></i> </button> <button v-if="isImage" v-tooltip="i18n.ts.cropImage" class="_button" :class="$style.fileQuickActionsOthersButton" @click="crop()"> <i class="ph-crop ph-bold ph-lg"></i> @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_button" :class="$style.fileAltEditBtn" @click="describe()"> <MkKeyValue> <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ph-pencil ph-bold ph-lg" :class="$style.fileAltEditIcon"></i></template> + <template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ph-pencil-simple ph-bold ph-lg" :class="$style.fileAltEditIcon"></i></template> </MkKeyValue> </button> <MkKeyValue :class="$style.fileMetaDataChildren"> @@ -79,7 +79,8 @@ import bytes from '@/filters/bytes.js'; import { infoImageUrl } from '@/instance.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -94,7 +95,7 @@ const isImage = computed(() => file.value?.type.startsWith('image/')); async function fetch() { fetching.value = true; - file.value = await os.api('drive/files/show', { + file.value = await misskeyApi('drive/files/show', { fileId: props.fileId, }).catch((err) => { console.error(err); @@ -179,7 +180,7 @@ async function deleteFile() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('driveFileDeleteConfirm', { name: file.value.name }), + text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }), }); if (canceled) return; diff --git a/packages/frontend/src/pages/drive.file.notes.vue b/packages/frontend/src/pages/drive.file.notes.vue index ee1a0ee9b0..ca63d43747 100644 --- a/packages/frontend/src/pages/drive.file.notes.vue +++ b/packages/frontend/src/pages/drive.file.notes.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue index b1bb84b488..7cb2976e76 100644 --- a/packages/frontend/src/pages/drive.file.vue +++ b/packages/frontend/src/pages/drive.file.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,13 +9,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/> </template> - <MkSpacer v-if="tab === 'info'" :contentMax="800"> - <XFileInfo :fileId="fileId"/> - </MkSpacer> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSpacer v-if="tab === 'info'" key="info" :contentMax="800"> + <XFileInfo :fileId="fileId"/> + </MkSpacer> - <MkSpacer v-else-if="tab === 'notes'" :contentMax="800"> - <XNotes :fileId="fileId"/> - </MkSpacer> + <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800"> + <XNotes :fileId="fileId"/> + </MkSpacer> + </MkHorizontalSwipe> </MkStickyContainer> </template> @@ -23,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const props = defineProps<{ fileId: string; @@ -42,11 +45,11 @@ const headerTabs = computed(() => [{ }, { key: 'notes', title: i18n.ts._fileViewer.attachedNotes, - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts._fileViewer.title, icon: 'ph-file-text ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue index f3a3af677f..7403986061 100644 --- a/packages/frontend/src/pages/drive.vue +++ b/packages/frontend/src/pages/drive.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,9 +22,9 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: folder.value ? folder.value.name : i18n.ts.drive, icon: 'ph-cloud ph-bold ph-lg', hideHeader: true, -}))); +})); </script> diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue new file mode 100644 index 0000000000..f1fa580c36 --- /dev/null +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -0,0 +1,1499 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkSpacer :contentMax="800"> + <div :class="$style.root"> + <div v-if="!gameLoaded" :class="$style.loadingScreen"> + <div>{{ i18n.ts.loading }}<MkEllipsis/></div> + </div> + <!-- ↓に対してTransitionコンポーネントを使うと何故かkeyを指定していてもキャッシュが効かず様々なコンポーネントが都度再評価されてパフォーマンスが低下する --> + <div v-show="gameLoaded" class="_gaps_s"> + <div v-if="readyGo === 'ready'" :class="$style.readyGo_bg"> + </div> + <Transition + :enterActiveClass="$style.transition_zoom_enterActive" + :leaveActiveClass="$style.transition_zoom_leaveActive" + :enterFromClass="$style.transition_zoom_enterFrom" + :leaveToClass="$style.transition_zoom_leaveTo" + :moveClass="$style.transition_zoom_move" + mode="default" + > + <div v-if="readyGo === 'ready'" :class="$style.readyGo_ready"> + <img src="/client-assets/drop-and-fusion/ready.png" :class="$style.readyGo_img"/> + </div> + <div v-else-if="readyGo === 'go'" :class="$style.readyGo_go"> + <img src="/client-assets/drop-and-fusion/go.png" :class="$style.readyGo_img"/> + </div> + </Transition> + + <div :class="$style.header"> + <div class="_woodenFrame" :class="[$style.headerTitle]"> + <div class="_woodenFrameInner"> + <b>{{ i18n.ts.bubbleGame }}</b> + <div>- {{ gameMode.toUpperCase() }} -</div> + </div> + </div> + <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="_woodenFrameInner" :class="$style.stock" style="text-align: center;"> + <TransitionGroup + :enterActiveClass="$style.transition_stock_enterActive" + :leaveActiveClass="$style.transition_stock_leaveActive" + :enterFromClass="$style.transition_stock_enterFrom" + :leaveToClass="$style.transition_stock_leaveTo" + :moveClass="$style.transition_stock_move" + > + <img v-for="x in stock" :key="x.id" :src="getTextureImageUrl(x.mono)" style="width: 32px; vertical-align: bottom;"/> + </TransitionGroup> + </div> + </div> + </div> + + <div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove"> + <img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/> + <img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/> + <canvas ref="canvasEl" :class="$style.canvas"/> + <Transition + :enterActiveClass="$style.transition_combo_enterActive" + :leaveActiveClass="$style.transition_combo_leaveActive" + :enterFromClass="$style.transition_combo_enterFrom" + :leaveToClass="$style.transition_combo_leaveTo" + :moveClass="$style.transition_combo_move" + > + <div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div> + </Transition> + <div v-if="!isGameOver && !replaying && readyGo !== 'ready'" :class="$style.dropperContainer" :style="{ left: dropperX + 'px' }"> + <!--<img v-if="currentPick" src="/client-assets/drop-and-fusion/dropper.png" :class="$style.dropper" :style="{ left: dropperX + 'px' }"/>--> + <Transition + :enterActiveClass="$style.transition_picked_enterActive" + :leaveActiveClass="$style.transition_picked_leaveActive" + :enterFromClass="$style.transition_picked_enterFrom" + :leaveToClass="$style.transition_picked_leaveTo" + :moveClass="$style.transition_picked_move" + mode="out-in" + > + <img v-if="currentPick" :key="currentPick.id" :src="getTextureImageUrl(currentPick.mono)" :class="$style.currentMono" :style="{ marginBottom: -((currentPick?.mono.sizeY * viewScale) / 2) + 'px', left: -((currentPick?.mono.sizeX * viewScale) / 2) + 'px', width: `${currentPick?.mono.sizeX * viewScale}px` }"/> + </Transition> + <template v-if="dropReady && currentPick"> + <img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow"/> + <div :class="$style.dropGuide"/> + </template> + </div> + <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>{{ 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="ph-play ph-bold ph-lg"></i> {{ i18n.ts.replaying }}</span></div> + </div> + + <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="_woodenFrameInner"> + <div class="_buttonsCenter"> + <MkButton @click="endReplay"><i class="ph-stop ph-bold ph-lg"></i> {{ i18n.ts.endReplay }}</MkButton> + <MkButton :primary="replayPlaybackRate === 4" @click="replayPlaybackRate = replayPlaybackRate === 4 ? 1 : 4"><i class="ph-skip-forward ph-bold ph-lg"></i> x4</MkButton> + <MkButton :primary="replayPlaybackRate === 16" @click="replayPlaybackRate = replayPlaybackRate === 16 ? 1 : 16"><i class="ph-skip-forward ph-bold ph-lg"></i> x16</MkButton> + </div> + </div> + </div> + + <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">{{ i18n.ts.copyReplayData }}</MkButton> + </div> + </div> + </div> + + <div style="display: flex;"> + <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="_woodenFrame" style="margin-left: auto;"> + <div class="_woodenFrameInner" style="text-align: center;"> + <div @click="showConfig = !showConfig"><i class="ph-gear ph-bold ph-lg"></i></div> + </div> + </div> + </div> + + <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> + </MkRange> + <MkRange v-model="sfxVolume" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`" :continuousUpdate="true" @dragEnded="(v) => updateSettings('sfxVolume', v)"> + <template #label>{{ i18n.ts.sfx }} {{ i18n.ts.volume }}</template> + </MkRange> + </div> + </div> + </div> + + <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;"> + <img :src="getTextureImageUrl(mono)" style="width: 32px; vertical-align: bottom;"/> + <div v-if="i < game.monoDefinitions.length - 1" style="display: inline-block; margin-left: 4px; vertical-align: bottom;"><i class="ph-arrow-fat-right ph-bold ph-lg"></i></div> + </div> + </div> + </div> + </div> + + <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> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; +import * as Matter from 'matter-js'; +import * as Misskey from 'misskey-js'; +import { DropAndFusionGame, Mono } from 'misskey-bubble-game'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import * as os from '@/os.js'; +import MkNumber from '@/components/MkNumber.vue'; +import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; +import MkButton from '@/components/MkButton.vue'; +import { claimAchievement } from '@/scripts/achievements.js'; +import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { useInterval } from '@/scripts/use-interval.js'; +import { apiUrl } from '@/config.js'; +import { $i } from '@/account.js'; +import * as sound from '@/scripts/sound.js'; +import MkRange from '@/components/MkRange.vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard.js'; + +type FrontendMonoDefinition = { + id: string; + img: string; + imgSizeX: number; + imgSizeY: number; + spriteScale: number; + sfxPitch: number; +}; + +const NORAML_MONOS: FrontendMonoDefinition[] = [{ + id: '9377076d-c980-4d83-bdaf-175bc58275b7', + sfxPitch: 0.25, + img: '/client-assets/drop-and-fusion/normal_monos/exploding_head.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'be9f38d2-b267-4b1a-b420-904e22e80568', + sfxPitch: 0.5, + img: '/client-assets/drop-and-fusion/normal_monos/face_with_symbols_on_mouth.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'beb30459-b064-4888-926b-f572e4e72e0c', + sfxPitch: 0.75, + img: '/client-assets/drop-and-fusion/normal_monos/cold_face.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0', + sfxPitch: 1, + img: '/client-assets/drop-and-fusion/normal_monos/zany_face.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a', + sfxPitch: 1.5, + img: '/client-assets/drop-and-fusion/normal_monos/pleading_face.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '249c728e-230f-4332-bbbf-281c271c75b2', + sfxPitch: 2, + img: '/client-assets/drop-and-fusion/normal_monos/face_with_open_mouth.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '23d67613-d484-4a93-b71e-3e81b19d6186', + sfxPitch: 2.5, + img: '/client-assets/drop-and-fusion/normal_monos/smiling_face_with_sunglasses.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99', + sfxPitch: 3, + img: '/client-assets/drop-and-fusion/normal_monos/grinning_squinting_face.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5', + sfxPitch: 3.5, + img: '/client-assets/drop-and-fusion/normal_monos/smiling_face_with_hearts.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '64ec4add-ce39-42b4-96cb-33908f3f118d', + sfxPitch: 4, + img: '/client-assets/drop-and-fusion/normal_monos/heart_suit.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}]; + +const YEN_MONOS: FrontendMonoDefinition[] = [{ + id: '880f9bd9-802f-4135-a7e1-fd0e0331f726', + sfxPitch: 0.25, + img: '/client-assets/drop-and-fusion/yen_monos/10000yen.png', + imgSizeX: 512, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: 'e807beb6-374a-4314-9cc2-aa5f17d96b6b', + sfxPitch: 0.5, + img: '/client-assets/drop-and-fusion/yen_monos/5000yen.png', + imgSizeX: 512, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: '033445b7-8f90-4fc9-beca-71a9e87cb530', + sfxPitch: 0.75, + img: '/client-assets/drop-and-fusion/yen_monos/2000yen.png', + imgSizeX: 512, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: '410a09ec-5f7f-46f6-b26f-cbca4ccbd091', + sfxPitch: 1, + img: '/client-assets/drop-and-fusion/yen_monos/1000yen.png', + imgSizeX: 512, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: '2aae82bc-3fa4-49ad-a6b5-94d888e809f5', + sfxPitch: 1.5, + img: '/client-assets/drop-and-fusion/yen_monos/500yen.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: 'a619bd67-d08f-4cc0-8c7e-c8072a4950cd', + sfxPitch: 2, + img: '/client-assets/drop-and-fusion/yen_monos/100yen.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: 'c1c5d8e4-17d6-4455-befd-12154d731faa', + sfxPitch: 2.5, + img: '/client-assets/drop-and-fusion/yen_monos/50yen.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: '7082648c-e428-44c4-887a-25c07a8ebdd5', + sfxPitch: 3, + img: '/client-assets/drop-and-fusion/yen_monos/10yen.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: '0d8d40d5-e6e0-4d26-8a95-b8d842363379', + sfxPitch: 3.5, + img: '/client-assets/drop-and-fusion/yen_monos/5yen.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 0.97, +}, { + id: '9dec1b38-d99d-40de-8288-37367b983d0d', + sfxPitch: 4, + img: '/client-assets/drop-and-fusion/yen_monos/1yen.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 0.97, +}]; + +const SQUARE_MONOS: FrontendMonoDefinition[] = [{ + id: 'f75fd0ba-d3d4-40a4-9712-b470e45b0525', + sfxPitch: 0.25, + img: '/client-assets/drop-and-fusion/square_monos/keycap_10.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '7b70f4af-1c01-45fd-af72-61b1f01e03d1', + sfxPitch: 0.5, + img: '/client-assets/drop-and-fusion/square_monos/keycap_9.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '41607ef3-b6d6-4829-95b6-3737bf8bb956', + sfxPitch: 0.75, + img: '/client-assets/drop-and-fusion/square_monos/keycap_8.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '8a8310d2-0374-460f-bb50-ca9cd3ee3416', + sfxPitch: 1, + img: '/client-assets/drop-and-fusion/square_monos/keycap_7.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '1092e069-fe1a-450b-be97-b5d477ec398c', + sfxPitch: 1.5, + img: '/client-assets/drop-and-fusion/square_monos/keycap_6.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '2294734d-7bb8-4781-bb7b-ef3820abf3d0', + sfxPitch: 2, + img: '/client-assets/drop-and-fusion/square_monos/keycap_5.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'ea8a61af-e350-45f7-ba6a-366fcd65692a', + sfxPitch: 2.5, + img: '/client-assets/drop-and-fusion/square_monos/keycap_4.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'd0c74815-fc1c-4fbe-9953-c92e4b20f919', + sfxPitch: 3, + img: '/client-assets/drop-and-fusion/square_monos/keycap_3.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: 'd8fbd70e-611d-402d-87da-1a7fd8cd2c8d', + sfxPitch: 3.5, + img: '/client-assets/drop-and-fusion/square_monos/keycap_2.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}, { + id: '35e476ee-44bd-4711-ad42-87be245d3efd', + sfxPitch: 4, + img: '/client-assets/drop-and-fusion/square_monos/keycap_1.png', + imgSizeX: 256, + imgSizeY: 256, + spriteScale: 1.12, +}]; + +const SWEETS_MONOS: FrontendMonoDefinition[] = [{ + id: '77f724c0-88be-4aeb-8e1a-a00ed18e3844', + sfxPitch: 0.25, + img: '/client-assets/drop-and-fusion/sweets_monos/shortcake_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: 'f3468ef4-2e1e-4906-8795-f147f39f7e1f', + sfxPitch: 0.5, + img: '/client-assets/drop-and-fusion/sweets_monos/pancakes_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: 'bcb41129-6f2d-44ee-89d3-86eb2df564ba', + sfxPitch: 0.75, + img: '/client-assets/drop-and-fusion/sweets_monos/shaved_ice_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: 'f058e1ad-1981-409b-b3a7-302de0a43744', + sfxPitch: 1, + img: '/client-assets/drop-and-fusion/sweets_monos/soft_ice_cream_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: 'd22cfe38-5a3b-4b9c-a1a6-907930a3d732', + sfxPitch: 1.5, + img: '/client-assets/drop-and-fusion/sweets_monos/doughnut_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: '79867083-a073-427e-ae82-07a70d9f3b4f', + sfxPitch: 2, + img: '/client-assets/drop-and-fusion/sweets_monos/custard_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: '2e152a12-a567-4100-b4d4-d15d81ba47b1', + sfxPitch: 2.5, + img: '/client-assets/drop-and-fusion/sweets_monos/chocolate_bar_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: '12250376-2258-4716-8eec-b3a7239461fc', + sfxPitch: 3, + img: '/client-assets/drop-and-fusion/sweets_monos/lollipop_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: '4d4f2668-4be7-44a3-aa3a-856df6e25aa6', + sfxPitch: 3.5, + img: '/client-assets/drop-and-fusion/sweets_monos/candy_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}, { + id: 'c9984b40-4045-44c3-b260-d47b7b4625b2', + sfxPitch: 4, + img: '/client-assets/drop-and-fusion/sweets_monos/cookie_color.svg', + imgSizeX: 32, + imgSizeY: 32, + spriteScale: 1, +}]; + +const props = defineProps<{ + gameMode: 'normal' | 'square' | 'yen' | 'sweets' | 'space'; + mute: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'end'): void; +}>(); + +const monoDefinitions = computed(() => { + return props.gameMode === 'normal' ? NORAML_MONOS : + props.gameMode === 'square' ? SQUARE_MONOS : + props.gameMode === 'yen' ? YEN_MONOS : + props.gameMode === 'sweets' ? SWEETS_MONOS : + props.gameMode === 'space' ? NORAML_MONOS : + [] as never; +}); + +function getScoreUnit(gameMode: string) { + return gameMode === 'normal' ? 'pt' : + gameMode === 'square' ? 'pt' : + gameMode === 'yen' ? '円' : + gameMode === 'sweets' ? 'kcal' : + '' as never; +} + +function getMonoRenderOptions(mono: Mono) { + const def = monoDefinitions.value.find(x => x.id === mono.id)!; + return { + + sprite: { + texture: def.img, + xScale: (mono.sizeX / def.imgSizeX) * def.spriteScale, + yScale: (mono.sizeY / def.imgSizeY) * def.spriteScale, + }, + + }; +} + +let viewScale = 1; +let seed: string = Date.now().toString(); +let containerElRect: DOMRect | null = null; +let logs: ReturnType<DropAndFusionGame['getLogs']> | null = null; +let endedAtFrame = 0; +let bgmNodes: ReturnType<typeof sound.createSourceNode> | null = null; +let renderer: Matter.Render | null = null; +let monoTextures: Record<string, Blob> = {}; +let monoTextureUrls: Record<string, string> = {}; +let tickRaf: number | null = null; +let game = new DropAndFusionGame({ + seed: seed, + gameMode: props.gameMode, + getMonoRenderOptions, +}); +attachGameEvents(); + +const containerEl = shallowRef<HTMLElement>(); +const canvasEl = shallowRef<HTMLCanvasElement>(); +const dropperX = ref(0); +const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null); +const stock = shallowRef<{ id: string; mono: Mono }[]>([]); +const holdingStock = shallowRef<{ id: string; mono: Mono } | null>(null); +const score = ref(0); +const combo = ref(0); +const comboPrev = ref(0); +const maxCombo = ref(0); +const dropReady = ref(true); +const isGameOver = ref(false); +const gameLoaded = ref(false); +const readyGo = ref<'ready' | 'go' | null>('ready'); +const highScore = ref<number | null>(null); +const yenTotal = ref<number | null>(null); +const showConfig = ref(false); +const replaying = ref(false); +const replayPlaybackRate = ref(1); +const currentFrame = ref(0); +const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); +const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); + +watch(replayPlaybackRate, (newValue) => { + game.replayPlaybackRate = newValue; +}); + +watch(bgmVolume, (newValue) => { + if (bgmNodes) { + bgmNodes.gainNode.gain.value = props.mute ? 0 : newValue; + } +}); + +function createRendererInstance(game: DropAndFusionGame) { + return Matter.Render.create({ + engine: game.engine, + canvas: canvasEl.value!, + options: { + width: game.GAME_WIDTH, + height: game.GAME_HEIGHT, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + wireframes: false, + showSleeping: false, + pixelRatio: Math.max(2, window.devicePixelRatio), + }, + }); +} + +function loadMonoTextures() { + async function loadSingleMonoTexture(mono: FrontendMonoDefinition) { + if (renderer == null) return; + + // Matter-js内にキャッシュがある場合はスキップ + if (renderer.textures[mono.img]) return; + + let src = mono.img; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (monoTextureUrls[mono.img]) { + src = monoTextureUrls[mono.img]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (monoTextures[mono.img]) { + src = URL.createObjectURL(monoTextures[mono.img]); + monoTextureUrls[mono.img] = src; + } else { + const res = await fetch(mono.img); + const blob = await res.blob(); + monoTextures[mono.img] = blob; + src = URL.createObjectURL(blob); + monoTextureUrls[mono.img] = src; + } + + const image = new Image(); + image.src = src; + renderer.textures[mono.img] = image; + } + + return Promise.all(monoDefinitions.value.map(x => loadSingleMonoTexture(x))); +} + +function getTextureImageUrl(mono: Mono) { + const def = monoDefinitions.value.find(x => x.id === mono.id)!; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (monoTextureUrls[def.img]) { + return monoTextureUrls[def.img]; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (monoTextures[def.img]) { + // Gameクラス内にキャッシュがある場合はそれを使う + const out = URL.createObjectURL(monoTextures[def.img]); + monoTextureUrls[def.img] = out; + return out; + } else { + return def.img; + } +} + +function tick() { + const hasNextTick = game.tick(); + if (hasNextTick) { + tickRaf = window.requestAnimationFrame(tick); + } else { + tickRaf = null; + } +} + +function tickReplay() { + let hasNextTick; + for (let i = 0; i < replayPlaybackRate.value; i++) { + const log = logs!.find(x => x.frame === game.frame); + if (log) { + switch (log.operation) { + case 'drop': { + game.drop(log.x); + break; + } + case 'hold': { + game.hold(); + break; + } + case 'surrender': { + game.surrender(); + break; + } + default: + break; + } + } + + hasNextTick = game.tick(); + currentFrame.value = game.frame; + if (!hasNextTick) break; + } + + if (hasNextTick) { + tickRaf = window.requestAnimationFrame(tickReplay); + } else { + tickRaf = null; + } +} + +async function start() { + renderer = createRendererInstance(game); + await loadMonoTextures(); + Matter.Render.lookAt(renderer, { + min: { x: 0, y: 0 }, + max: { x: game.GAME_WIDTH, y: game.GAME_HEIGHT }, + }); + Matter.Render.run(renderer); + game.start(); + window.requestAnimationFrame(tick); + + gameLoaded.value = true; + + window.setTimeout(() => { + readyGo.value = 'go'; + window.setTimeout(() => { + readyGo.value = null; + }, 1000); + }, 1500); +} + +function onClick(ev: MouseEvent) { + if (!containerElRect) return; + if (replaying.value) return; + const x = (ev.clientX - containerElRect.left) / viewScale; + game.drop(x); +} + +function onTouchend(ev: TouchEvent) { + if (!containerElRect) return; + if (replaying.value) return; + const x = (ev.changedTouches[0].clientX - containerElRect.left) / viewScale; + game.drop(x); +} + +function onMousemove(ev: MouseEvent) { + if (!containerElRect) return; + const x = (ev.clientX - containerElRect.left); + moveDropper(containerElRect, x); +} + +function onTouchmove(ev: TouchEvent) { + if (!containerElRect) return; + const x = (ev.touches[0].clientX - containerElRect.left); + moveDropper(containerElRect, x); +} + +function moveDropper(rect: DOMRect, x: number) { + dropperX.value = Math.min(rect.width * ((game.GAME_WIDTH - game.PLAYAREA_MARGIN) / game.GAME_WIDTH), Math.max(rect.width * (game.PLAYAREA_MARGIN / game.GAME_WIDTH), x)); +} + +function hold() { + game.hold(); +} + +async function surrender() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + game.surrender(); +} + +async function restart() { + reset(); + game = new DropAndFusionGame({ + seed: seed, + gameMode: props.gameMode, + getMonoRenderOptions, + }); + attachGameEvents(); + await start(); +} + +function reset() { + dispose(); + seed = Date.now().toString(); + isGameOver.value = false; + replaying.value = false; + replayPlaybackRate.value = 1; + currentPick.value = null; + dropReady.value = true; + stock.value = []; + holdingStock.value = null; + score.value = 0; + combo.value = 0; + comboPrev.value = 0; + maxCombo.value = 0; + gameLoaded.value = false; + readyGo.value = null; +} + +function dispose() { + game.dispose(); + if (renderer) Matter.Render.stop(renderer); + if (tickRaf) { + window.cancelAnimationFrame(tickRaf); + } +} + +function backToTitle() { + emit('end'); +} + +function replay() { + replaying.value = true; + dispose(); + game = new DropAndFusionGame({ + seed: seed, + gameMode: props.gameMode, + getMonoRenderOptions, + }); + attachGameEvents(); + os.promiseDialog(loadMonoTextures(), async () => { + renderer = createRendererInstance(game); + Matter.Render.lookAt(renderer, { + min: { x: 0, y: 0 }, + max: { x: game.GAME_WIDTH, y: game.GAME_HEIGHT }, + }); + Matter.Render.run(renderer); + game.start(); + window.requestAnimationFrame(tickReplay); + }); +} + +function endReplay() { + replaying.value = false; + dispose(); +} + +function exportLog() { + if (!logs) return; + const data = JSON.stringify({ + v: game.GAME_VERSION, + m: props.gameMode, + s: seed, + d: new Date().toISOString(), + l: DropAndFusionGame.serializeLogs(logs), + }); + copyToClipboard(data); + os.success(); +} + +function updateSettings< + K extends keyof typeof defaultStore.state.dropAndFusion, + V extends typeof defaultStore.state.dropAndFusion[K], +>(key: K, value: V) { + const changes: { [P in K]?: V } = {}; + changes[key] = value; + defaultStore.set('dropAndFusion', { + ...defaultStore.state.dropAndFusion, + ...changes, + }); +} + +function loadImage(url: string) { + return new Promise<HTMLImageElement>(res => { + const img = new Image(); + img.src = url; + img.addEventListener('load', () => { + res(img); + }); + }); +} + +function getGameImageDriveFile() { + return new Promise<Misskey.entities.DriveFile | null>(res => { + const dcanvas = document.createElement('canvas'); + dcanvas.width = game.GAME_WIDTH; + dcanvas.height = game.GAME_HEIGHT; + const ctx = dcanvas.getContext('2d'); + if (!ctx || !canvasEl.value) return res(null); + Promise.all([ + loadImage('/client-assets/drop-and-fusion/frame-light.svg'), + loadImage('/client-assets/drop-and-fusion/logo.png'), + ]).then((images) => { + const [frame, logo] = images; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, game.GAME_WIDTH, game.GAME_HEIGHT); + + ctx.drawImage(frame, 0, 0, game.GAME_WIDTH, game.GAME_HEIGHT); + ctx.drawImage(canvasEl.value!, 0, 0, game.GAME_WIDTH, game.GAME_HEIGHT); + + ctx.fillStyle = '#000'; + ctx.font = '16px bold sans-serif'; + ctx.textBaseline = 'top'; + ctx.fillText(`SCORE: ${score.value.toLocaleString()}${getScoreUnit(props.gameMode)}`, 10, 10); + + ctx.globalAlpha = 0.7; + ctx.drawImage(logo, game.GAME_WIDTH * 0.55, 6, game.GAME_WIDTH * 0.45, game.GAME_WIDTH * 0.45 * (logo.height / logo.width)); + ctx.globalAlpha = 1; + + dcanvas.toBlob(blob => { + if (!blob) return res(null); + if ($i == null) return res(null); + const formData = new FormData(); + formData.append('file', blob); + formData.append('name', `bubble-game-${Date.now()}.png`); + formData.append('isSensitive', 'false'); + formData.append('i', $i.token); + if (defaultStore.state.uploadFolder) { + formData.append('folderId', defaultStore.state.uploadFolder); + } + + window.fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: formData, + }) + .then(response => response.json()) + .then(f => { + res(f); + }); + }, 'image/png'); + + dcanvas.remove(); + }); + }); +} + +async function share() { + const uploading = getGameImageDriveFile(); + os.promiseDialog(uploading); + const file = await uploading; + if (!file) return; + os.post({ + initialText: `#BubbleGame (${props.gameMode}) +SCORE: ${score.value.toLocaleString()}${getScoreUnit(props.gameMode)}`, + initialFiles: [file], + instant: true, + }); +} + +function attachGameEvents() { + game.addListener('changeScore', value => { + score.value = value; + }); + + game.addListener('changeCombo', value => { + if (value === 0) { + comboPrev.value = combo.value; + } else { + comboPrev.value = value; + } + maxCombo.value = Math.max(maxCombo.value, value); + combo.value = value; + }); + + game.addListener('changeStock', value => { + currentPick.value = JSON.parse(JSON.stringify(value[0])); + stock.value = JSON.parse(JSON.stringify(value.slice(1))); + }); + + game.addListener('changeHolding', value => { + holdingStock.value = value; + + if (!props.mute) { + sound.playUrl('/client-assets/drop-and-fusion/hold.mp3', { + volume: 0.5 * sfxVolume.value, + playbackRate: replayPlaybackRate.value, + }); + } + }); + + game.addListener('dropped', (x) => { + if (!props.mute) { + const panV = x - game.PLAYAREA_MARGIN; + const panW = game.GAME_WIDTH - game.PLAYAREA_MARGIN - game.PLAYAREA_MARGIN; + const pan = ((panV / panW) - 0.5) * 2; + if (props.gameMode === 'yen') { + sound.playUrl('/client-assets/drop-and-fusion/drop_yen.mp3', { + volume: sfxVolume.value, + pan, + playbackRate: replayPlaybackRate.value, + }); + } else { + sound.playUrl('/client-assets/drop-and-fusion/drop.mp3', { + volume: sfxVolume.value, + pan, + playbackRate: replayPlaybackRate.value, + }); + } + } + + if (replaying.value) return; + + dropReady.value = false; + window.setTimeout(() => { + if (!isGameOver.value) { + dropReady.value = true; + } + }, game.frameToMs(game.DROP_COOLTIME)); + }); + + game.addListener('fusioned', (x, y, nextMono, scoreDelta) => { + if (!canvasEl.value) return; + + const rect = canvasEl.value.getBoundingClientRect(); + const domX = rect.left + (x * viewScale); + const domY = rect.top + (y * viewScale); + const scoreUnit = getScoreUnit(props.gameMode); + os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end'); + os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, {}, 'end'); + + if (nextMono) { + const def = monoDefinitions.value.find(x => x.id === nextMono.id)!; + if (!props.mute) { + const panV = x - game.PLAYAREA_MARGIN; + const panW = game.GAME_WIDTH - game.PLAYAREA_MARGIN - game.PLAYAREA_MARGIN; + const pan = ((panV / panW) - 0.5) * 2; + const pitch = def.sfxPitch; + if (props.gameMode === 'yen') { + sound.playUrl('/client-assets/drop-and-fusion/fusion_yen.mp3', { + volume: 0.25 * sfxVolume.value, + pan: pan, + playbackRate: (pitch / 4) * replayPlaybackRate.value, + }); + } else { + sound.playUrl('/client-assets/drop-and-fusion/fusion.mp3', { + volume: sfxVolume.value, + pan: pan, + playbackRate: pitch * replayPlaybackRate.value, + }); + } + } + } else { + if (!props.mute) { + // TODO: 融合後のモノがない場合でも何らかの効果音を再生 + } + } + }); + + const minCollisionEnergyForSound = 2.5; + const maxCollisionEnergyForSound = 9; + const soundPitchMax = 4; + const soundPitchMin = 0.5; + + game.addListener('collision', (energy, bodyA, bodyB) => { + if (!props.mute && (energy > minCollisionEnergyForSound)) { + const volume = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4; + const panV = + bodyA.label === '_wall_' ? bodyB.position.x - game.PLAYAREA_MARGIN : + bodyB.label === '_wall_' ? bodyA.position.x - game.PLAYAREA_MARGIN : + ((bodyA.position.x + bodyB.position.x) / 2) - game.PLAYAREA_MARGIN; + const panW = game.GAME_WIDTH - game.PLAYAREA_MARGIN - game.PLAYAREA_MARGIN; + const pan = ((panV / panW) - 0.5) * 2; + const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); + + if (props.gameMode === 'yen') { + sound.playUrl('/client-assets/drop-and-fusion/collision_yen.mp3', { + volume: volume * sfxVolume.value, + pan: pan, + playbackRate: Math.max(1, pitch) * replayPlaybackRate.value, + }); + } else { + sound.playUrl('/client-assets/drop-and-fusion/collision.mp3', { + volume: volume * sfxVolume.value, + pan: pan, + playbackRate: pitch * replayPlaybackRate.value, + }); + } + } + }); + + game.addListener('monoAdded', (mono) => { + if (replaying.value) return; + + // 実績関連 + if (mono.level === 10) { + claimAchievement('bubbleGameExplodingHead'); + + const monos = game.getActiveMonos(); + if (monos.filter(x => x.level === 10).length >= 2) { + claimAchievement('bubbleGameDoubleExplodingHead'); + } + } + }); + + game.addListener('gameOver', () => { + if (!props.mute) { + if (props.gameMode === 'yen') { + sound.playUrl('/client-assets/drop-and-fusion/gameover_yen.mp3', { + volume: 0.5 * sfxVolume.value, + }); + } else { + sound.playUrl('/client-assets/drop-and-fusion/gameover.mp3', { + volume: sfxVolume.value, + }); + } + } + + if (replaying.value) { + endReplay(); + return; + } + + logs = game.getLogs(); + endedAtFrame = game.frame; + currentPick.value = null; + dropReady.value = false; + isGameOver.value = true; + + misskeyApi('bubble-game/register', { + seed, + score: score.value, + gameMode: props.gameMode, + gameVersion: game.GAME_VERSION, + logs: DropAndFusionGame.serializeLogs(logs), + }); + + if (props.gameMode === 'yen') { + yenTotal.value = (yenTotal.value ?? 0) + score.value; + misskeyApi('i/registry/set', { + scope: ['dropAndFusionGame'], + key: 'yenTotal', + value: yenTotal.value, + }); + } + + if (score.value > (highScore.value ?? 0)) { + highScore.value = score.value; + + misskeyApi('i/registry/set', { + scope: ['dropAndFusionGame'], + key: 'highScore:' + props.gameMode, + value: highScore.value, + }); + } + }); +} + +useInterval(() => { + if (!canvasEl.value) return; + const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width; + if (actualCanvasWidth === 0) return; + viewScale = actualCanvasWidth / game.GAME_WIDTH; + containerElRect = containerEl.value?.getBoundingClientRect() ?? null; +}, 1000, { immediate: false, afterMounted: true }); + +onMounted(async () => { + try { + highScore.value = await misskeyApi('i/registry/get', { + scope: ['dropAndFusionGame'], + key: 'highScore:' + props.gameMode, + }); + } catch (err) { + highScore.value = null; + } + + if (props.gameMode === 'yen') { + try { + yenTotal.value = await misskeyApi('i/registry/get', { + scope: ['dropAndFusionGame'], + key: 'yenTotal', + }); + } catch (err: any) { + if (err.code === 'NO_SUCH_KEY') { + // nop + } else { + os.alert({ + type: 'error', + text: i18n.ts.cannotLoad, + }); + return; + } + } + } + + /* + const getVerticesFromSvg = async (path: string) => { + const svgDoc = await fetch(path) + .then((response) => response.text()) + .then((svgString) => { + const parser = new DOMParser(); + return parser.parseFromString(svgString, 'image/svg+xml'); + }); + const pathDatas = svgDoc.querySelectorAll('path'); + if (!pathDatas) return; + const vertices = Array.from(pathDatas).map((pathData) => { + return Matter.Svg.pathToVertices(pathData); + }); + return vertices; + }; + + getVerticesFromSvg('/client-assets/drop-and-fusion/sweets_monos/verts/doughnut_color.svg').then((vertices) => { + console.log('doughnut_color', vertices); + }); + */ + + await start(); + + const bgmBuffer = await sound.loadAudio('/client-assets/drop-and-fusion/bgm_1.mp3'); + if (!bgmBuffer) return; + bgmNodes = sound.createSourceNode(bgmBuffer, { + volume: props.mute ? 0 : bgmVolume.value, + }); + if (!bgmNodes) return; + bgmNodes.soundSource.loop = true; + bgmNodes.soundSource.start(); +}); + +onUnmounted(() => { + dispose(); + bgmNodes?.soundSource.stop(); +}); + +onDeactivated(() => { + dispose(); + bgmNodes?.soundSource.stop(); +}); + +definePageMetadata(() => ({ + title: i18n.ts.bubbleGame, + icon: 'ph-orange-slice ph-bold ph-lg', +})); +</script> + +<style lang="scss" module> +.transition_zoom_move, +.transition_zoom_enterActive, +.transition_zoom_leaveActive { + transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important; +} +.transition_zoom_enterFrom, +.transition_zoom_leaveTo { + opacity: 0; + transform: scale(0.8); +} + +.transition_stock_move, +.transition_stock_enterActive, +.transition_stock_leaveActive { + transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important; +} +.transition_stock_enterFrom, +.transition_stock_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_stock_leaveActive { + position: absolute; +} + +.transition_picked_move, +.transition_picked_enterActive { + transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important; +} +.transition_picked_leaveActive { + transition: all 0s !important; +} +.transition_picked_enterFrom, +.transition_picked_leaveTo { + opacity: 0; + transform: translateY(-50px); +} +.transition_picked_leaveActive { + position: absolute; +} + +.transition_combo_move, +.transition_combo_enterActive { + transition: all 0s !important; +} +.transition_combo_leaveActive { + transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important; +} +.transition_combo_enterFrom, +.transition_combo_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_combo_leaveActive { + position: absolute; +} + +.root { + margin: 0 auto; + max-width: 600px; + user-select: none; + + * { + user-select: none; + } +} + +.loadingScreen { + text-align: center; + padding: 32px; +} + +.readyGo_bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + backdrop-filter: blur(4px); +} + +.readyGo_ready, +.readyGo_go { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 101; + pointer-events: none; +} + +.readyGo_img { + display: block; + width: 250px; + max-width: 100%; +} + +.header { + position: relative; + z-index: 10; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + gap: 8px; + + > .headerTitle { + text-align: center; + } + + @media (min-width: 500px) { + grid-template-columns: 1fr auto; + grid-template-rows: auto; + + > .headerTitle { + text-align: start; + } + } +} + +.mainFrameImg { + position: absolute; + top: 0; + left: 0; + width: 100%; + // なんかiOSでちらつく + //filter: drop-shadow(0 6px 16px #0007); + pointer-events: none; + user-select: none; +} + +.canvas { + position: relative; + display: block; + z-index: 1; + width: 100% !important; + height: auto !important; + pointer-events: none; + user-select: none; +} + +.gameContainer { + position: relative; + margin-top: -20px; +} + +.stock { + pointer-events: none; + user-select: none; +} + +.combo { + position: absolute; + z-index: 3; + top: 50%; + width: 100%; + text-align: center; + font-weight: bold; + font-style: oblique; + color: #fff; + -webkit-text-stroke: 1px rgb(255, 145, 0); + text-shadow: 0 0 6px #0005; + pointer-events: none; + user-select: none; +} + +.dropperContainer { + position: absolute; + top: 0; + height: 100%; + z-index: 2; + pointer-events: none; + user-select: none; + will-change: left; +} + +.currentMono { + position: absolute; + display: block; + bottom: 88%; + z-index: 2; + filter: drop-shadow(0 6px 16px #0007); +} + +.dropper { + position: relative; + top: 0; + width: 70px; + margin-top: -10px; + margin-left: -30px; + z-index: 2; + filter: drop-shadow(0 6px 16px #0007); +} + +.currentMonoArrow { + position: absolute; + width: 20px; + bottom: 80%; + left: -10px; + z-index: 3; + animation: currentMonoArrow 2s ease infinite; +} + +.dropGuide { + position: absolute; + z-index: 3; + bottom: 0; + width: 3px; + margin-left: -2px; + height: 85%; + background: #f002; +} + +.gameOverLabel { + position: absolute; + z-index: 10; + top: 50%; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 50px); + max-width: 320px; + padding: 16px; + box-sizing: border-box; + background: #0007; + border-radius: 16px; + color: #fff; + text-align: center; + font-weight: bold; +} + +.gameOver { + .canvas { + filter: grayscale(1); + } +} + +.replayIndicator { + position: absolute; + z-index: 10; + left: 10px; + bottom: 10px; + padding: 6px 8px; + color: #f00; + font-weight: bold; + background: #0008; + border-radius: 6px; + pointer-events: none; +} + +.replayIndicatorText { + animation: replayIndicator-blink 2s infinite; +} + +@keyframes replayIndicator-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes currentMonoArrow { + 0% { transform: translateY(0); } + 25% { transform: translateY(-8px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} +</style> diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue new file mode 100644 index 0000000000..3ece281468 --- /dev/null +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -0,0 +1,160 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Transition + :enterActiveClass="$style.transition_zoom_enterActive" + :leaveActiveClass="$style.transition_zoom_leaveActive" + :enterFromClass="$style.transition_zoom_enterFrom" + :leaveToClass="$style.transition_zoom_leaveTo" + :moveClass="$style.transition_zoom_move" + mode="out-in" +> + <MkSpacer v-if="!gameStarted" :contentMax="800"> + <div :class="$style.root"> + <div class="_gaps"> + <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="_woodenFrame" style="text-align: center;"> + <div class="_woodenFrameInner"> + <div class="_gaps" style="padding: 16px;"> + <MkSelect v-model="gameMode"> + <option value="normal">NORMAL</option> + <option value="square">SQUARE</option> + <option value="yen">YEN</option> + <option value="sweets">SWEETS</option> + <!--<option value="space">SPACE</option>--> + </MkSelect> + <MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton> + </div> + </div> + <div class="_woodenFrameInner"> + <div class="_gaps" style="padding: 16px;"> + <div style="font-size: 90%;"><i class="ph-music-notes ph-bold ph-lg"></i> {{ i18n.ts.soundWillBePlayed }}</div> + <MkSwitch v-model="mute"> + <template #label>{{ i18n.ts.mute }}</template> + </MkSwitch> + </div> + </div> + </div> + <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.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"/> + <MkUserName :user="r.user" :nowrap="true"/> + <b style="margin-left: auto;">{{ r.score.toLocaleString() }} {{ getScoreUnit(gameMode) }}</b> + </div> + </div> + <div v-else>{{ i18n.ts.loading }}</div> + </div> + </div> + </div> + <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> + <li>{{ i18n.ts._bubbleGame._howToPlay.section2 }}</li> + <li>{{ i18n.ts._bubbleGame._howToPlay.section3 }}</li> + </ol> + </div> + </div> + <div class="_woodenFrame"> + <div class="_woodenFrameInner"> + <div class="_gaps_s" style="padding: 16px;"> + <div><b>Credit</b></div> + <div> + <div>Ai-chan illustration: @poteriri@misskey.io</div> + <div>BGM: @ys@misskey.design</div> + </div> + </div> + </div> + </div> + </div> + </div> + </MkSpacer> + <XGame v-else :gameMode="gameMode" :mute="mute" @end="onGameEnd"/> +</Transition> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import XGame from './drop-and-fusion.game.vue'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import MkSelect from '@/components/MkSelect.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; + +const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal'); +const gameStarted = ref(false); +const mute = ref(false); +const ranking = ref(null); + +watch(gameMode, async () => { + ranking.value = await misskeyApiGet('bubble-game/ranking', { gameMode: gameMode.value }); +}, { immediate: true }); + +function getScoreUnit(gameMode: string) { + return gameMode === 'normal' ? 'pt' : + gameMode === 'square' ? 'pt' : + gameMode === 'yen' ? '円' : + gameMode === 'sweets' ? 'kcal' : + gameMode === 'space' ? 'pt' : + '' as never; +} + +async function start() { + gameStarted.value = true; +} + +function onGameEnd() { + gameStarted.value = false; +} + +definePageMetadata(() => ({ + title: i18n.ts.bubbleGame, + icon: 'ph-game-controller ph-bold ph-lg', +})); +</script> + +<style lang="scss" module> +.transition_zoom_move, +.transition_zoom_enterActive, +.transition_zoom_leaveActive { + transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important; +} +.transition_zoom_enterFrom, +.transition_zoom_leaveTo { + opacity: 0; + transform: scale(0.8); +} + +.root { + margin: 0 auto; + max-width: 600px; + user-select: none; + + * { + user-select: none; + } +} + +.rankingRecord { + display: flex; + line-height: 24px; + padding-top: 4px; + white-space: nowrap; + overflow: visible; + text-overflow: ellipsis; +} +</style> diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 82cfa92f6a..64960fd063 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -1,13 +1,15 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModalWindow - ref="dialog" - :width="400" - @close="dialog.close()" +<MkWindow + ref="windowEl" + :initialWidth="400" + :initialHeight="500" + :canResize="false" + @close="windowEl.close()" @closed="$emit('closed')" > <template v-if="emoji" #header>:{{ emoji.name }}:</template> @@ -39,9 +41,12 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput v-model="aliases" autocapitalize="off"> <template #label>{{ i18n.ts.tags }}</template> - <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> + <template #caption> + {{ i18n.ts.theKeywordWhenSearchingForCustomEmoji }}<br/> + {{ i18n.ts.setMultipleBySeparatingWithSpace }} + </template> </MkInput> - <MkInput v-model="license"> + <MkInput v-model="license" :mfmAutocomplete="true"> <template #label>{{ i18n.ts.license }}</template> </MkInput> <MkFolder> @@ -70,18 +75,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ph-check ph-bold ph-lg"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton> </div> </div> -</MkModalWindow> +</MkWindow> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkWindow from '@/components/MkWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { customEmojiCategories } from '@/custom-emojis.js'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -92,7 +98,7 @@ const props = defineProps<{ emoji?: any, }>(); -const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); +const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); const name = ref<string>(props.emoji ? props.emoji.name : ''); const category = ref<string>(props.emoji ? props.emoji.category : ''); const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : ''); @@ -104,7 +110,7 @@ const rolesThatCanBeUsedThisEmojiAsReaction = ref<Misskey.entities.Role[]>([]); const file = ref<Misskey.entities.DriveFile>(); watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => { - rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); + rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); }, { immediate: true }); const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); @@ -123,13 +129,13 @@ async function changeImage(ev) { } async function addRole() { - const roles = await os.api('admin/roles/list'); + const roles = await misskeyApi('admin/roles/list'); const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id); 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); } @@ -166,7 +172,7 @@ async function done() { }, }); - dialog.value.close(); + windowEl.value.close(); } else { const created = await os.apiWithDialog('admin/emoji/add', params); @@ -174,24 +180,24 @@ async function done() { created: created, }); - dialog.value.close(); + windowEl.value.close(); } } async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: name.value }), + text: i18n.tsx.removeAreYouSure({ x: name.value }), }); if (canceled) return; - os.api('admin/emoji/delete', { + misskeyApi('admin/emoji/delete', { id: props.emoji.id, }).then(() => { emit('done', { deleted: true, }); - dialog.value.close(); + windowEl.value.close(); }); } </script> diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index d94fe96fa2..c9805af51b 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,18 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; import * as os from '@/os.js'; +import * as Misskey from 'misskey-js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; +import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; const props = defineProps<{ - emoji: { - name: string; - aliases: string[]; - category: string; - url: string; - }; + emoji: Misskey.entities.EmojiSimple; }>(); function menu(ev) { @@ -42,12 +39,13 @@ function menu(ev) { }, { text: i18n.ts.info, icon: 'ph-info ph-bold ph-lg', - action: () => { - os.apiGet('emoji', { name: props.emoji.name }).then(res => { - os.alert({ - type: 'info', - text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`, - }); + action: async () => { + os.popup(MkCustomEmojiDetailedDialog, { + emoji: await misskeyApiGet('emoji', { + name: props.emoji.name, + }) + }, { + anchor: ev.target, }); }, }], ev.currentTarget ?? ev.target); diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index 000371528e..b5c8e70166 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue index d30e107e97..389cd23ad2 100644 --- a/packages/frontend/src/pages/explore.roles.vue +++ b/packages/frontend/src/pages/explore.roles.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -15,11 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkRolePreview from '@/components/MkRolePreview.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const roles = ref<Misskey.entities.Role[] | null>(null); -os.api('roles/list').then(res => { +misskeyApi('roles/list').then(res => { roles.value = res.filter(x => x.target === 'manual').sort((a, b) => b.displayOrder - a.displayOrder); }); </script> diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index fbca2b8ede..c9ab5443b6 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -68,7 +68,7 @@ import * as Misskey from 'misskey-js'; import MkUserList from '@/components/MkUserList.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkTab from '@/components/MkTab.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -123,14 +123,14 @@ const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, sort: '+createdAt', } }; -os.api('hashtags/list', { +misskeyApi('hashtags/list', { sort: '+attachedLocalUsers', attachedToLocalUserOnly: true, limit: 30, }).then(tags => { tagsLocal.value = tags; }); -os.api('hashtags/list', { +misskeyApi('hashtags/list', { sort: '+attachedRemoteUsers', attachedToRemoteUserOnly: true, limit: 30, diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index 9693e26598..c599a290f7 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -1,22 +1,22 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <div> - <div v-if="tab === 'featured'"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'featured'" key="featured"> <XFeatured/> </div> - <div v-else-if="tab === 'users'"> + <div v-else-if="tab === 'users'" key="users"> <XUsers/> </div> - <div v-else-if="tab === 'roles'"> + <div v-else-if="tab === 'roles'" key="roles"> <XRoles/> </div> - </div> + </MkHorizontalSwipe> </MkStickyContainer> </template> @@ -26,6 +26,7 @@ import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; @@ -59,8 +60,8 @@ const headerTabs = computed(() => [{ title: i18n.ts.roles, }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.explore, icon: 'ph-hash ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index 10f4a96a98..2827898194 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,10 +38,10 @@ const pagination = { limit: 10, }; -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.favorites, icon: 'ph-star ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 21e6d00613..53c8c78914 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="title"> <template #label>{{ i18n.ts._play.title }}</template> </MkInput> - <MkTextarea v-model="summary"> + <MkTextarea v-model="summary" :mfmAutocomplete="true" :mfmPreview="true"> <template #label>{{ i18n.ts._play.summary }}</template> </MkTextarea> <MkButton primary @click="selectPreset">{{ i18n.ts.selectFromPresets }}<i class="ph-caret-down ph-bold ph-lg"></i></MkButton> @@ -38,13 +38,14 @@ import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; const PRESET_DEFAULT = `/// @ 0.16.0 @@ -369,7 +370,7 @@ const flash = ref<Misskey.entities.Flash | null>(null); const visibility = ref<Misskey.entities.FlashUpdateRequest['visibility']>('public'); if (props.id) { - flash.value = await os.api('flash/show', { + flash.value = await misskeyApi('flash/show', { flashId: props.id, }); } @@ -437,7 +438,7 @@ function show() { async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: flash.value.title }), + text: i18n.tsx.deleteAreYouSure({ x: flash.value.title }), }); if (canceled) return; @@ -451,9 +452,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => flash.value ? { - title: i18n.ts._play.edit + ': ' + flash.value.title, -} : { - title: i18n.ts._play.new, +definePageMetadata(() => ({ + title: flash.value ? `${i18n.ts._play.edit}: ${flash.value.title}` : i18n.ts._play.new, })); </script> diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 2b9346fcac..7e56d3f51b 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,32 +7,34 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> - <div v-if="tab === 'featured'"> - <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> - <div class="_gaps_s"> - <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> - </div> - </MkPagination> - </div> - - <div v-else-if="tab === 'my'"> - <div class="_gaps"> - <MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ph-plus ph-bold ph-lg"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'featured'" key="featured"> + <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> <div class="_gaps_s"> <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> </div> </MkPagination> </div> - </div> - <div v-else-if="tab === 'liked'"> - <MkPagination v-slot="{items}" :pagination="likedFlashsPagination"> - <div class="_gaps_s"> - <MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/> + <div v-else-if="tab === 'my'" key="my"> + <div class="_gaps"> + <MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ph-plus ph-bold ph-lg"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> + <div class="_gaps_s"> + <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> + </div> + </MkPagination> </div> - </MkPagination> - </div> + </div> + + <div v-else-if="tab === 'liked'" key="liked"> + <MkPagination v-slot="{items}" :pagination="likedFlashsPagination"> + <div class="_gaps_s"> + <MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/> + </div> + </MkPagination> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -42,9 +44,10 @@ import { computed, ref } from 'vue'; import MkFlashPreview from '@/components/MkFlashPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; -import { useRouter } from '@/router.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -80,15 +83,15 @@ const headerTabs = computed(() => [{ }, { key: 'my', title: i18n.ts._play.my, - icon: 'ph-pencil-line ph-bold ph-lg', + icon: 'ph-pencil-simple-line ph-bold ph-lg', }, { key: 'liked', title: i18n.ts._play.liked, icon: 'ph-heart ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: 'Play', icon: 'ph-play ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 5fae1248e9..cbb52a2e23 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else :class="$style.ready"> <div class="_panel main"> <div class="title">{{ flash.title }}</div> - <div class="summary">{{ flash.summary }}</div> + <div class="summary"><Mfm :text="flash.summary"/></div> <MkButton class="start" gradate rounded large @click="start">Play</MkButton> <div class="info"> <span v-tooltip="i18n.ts.numberOfLikes"><i class="ph-heart ph-bold ph-lg"></i> {{ flash.likedCount }}</span> @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ph-code ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._play.viewSource }}</template> - <MkCode :code="flash.script" lang="is" :inline="false" class="_monospace"/> + <MkCode :code="flash.script" lang="is" class="_monospace"/> </MkFolder> <div :class="$style.footer"> <Mfm :text="`By @${flash.user.username}`"/> @@ -62,12 +62,13 @@ import * as Misskey from 'misskey-js'; import { Interpreter, Parser, values } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkAsUi from '@/components/MkAsUi.vue'; import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui.js'; -import { createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import MkFolder from '@/components/MkFolder.vue'; import MkCode from '@/components/MkCode.vue'; import { defaultStore } from '@/store.js'; @@ -84,7 +85,7 @@ const error = ref<any>(null); function fetchFlash() { flash.value = null; - os.api('flash/show', { + misskeyApi('flash/show', { flashId: props.id, }).then(_flash => { flash.value = _flash; @@ -162,15 +163,7 @@ async function run() { THIS_ID: values.STR(flash.value.id), THIS_URL: values.STR(`${url}/play/${flash.value.id}`), }, { - in: (q) => { - return new Promise(ok => { - os.inputText({ - title: q, - }).then(({ result: a }) => { - ok(a ?? ''); - }); - }); - }, + in: aiScriptReadline, out: (value) => { // nop }, @@ -212,15 +205,17 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => flash.value ? { - title: flash.value.title, - avatar: flash.value.user, - path: `/play/${flash.value.id}`, - share: { - title: flash.value.title, - text: flash.value.summary, - }, -} : null)); +definePageMetadata(() => ({ + title: flash.value ? flash.value.title : 'Play', + ...flash.value ? { + avatar: flash.value.user, + path: `/play/${flash.value.id}`, + share: { + title: flash.value.title, + text: flash.value.summary, + }, + } : {}, +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index d750664221..4cdfe28916 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -41,7 +41,7 @@ import { shallowRef, computed } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { infoImageUrl } from '@/instance.js'; @@ -54,13 +54,13 @@ const pagination = { }; function accept(user) { - os.api('following/requests/accept', { userId: user.id }).then(() => { + misskeyApi('following/requests/accept', { userId: user.id }).then(() => { paginationComponent.value.reload(); }); } function reject(user) { - os.api('following/requests/reject', { userId: user.id }).then(() => { + misskeyApi('following/requests/reject', { userId: user.id }).then(() => { paginationComponent.value.reload(); }); } @@ -69,10 +69,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.followRequests, icon: 'ph-user-plus ph-bold ph-lg', -}))); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue index a0a4a480b5..247b0ac639 100644 --- a/packages/frontend/src/pages/follow.vue +++ b/packages/frontend/src/pages/follow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,14 +12,15 @@ SPDX-License-Identifier: AGPL-3.0-only import { } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { mainRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from "@/store.js"; +import { defaultStore } from '@/store.js'; +import { mainRouter } from '@/router/main.js'; async function follow(user): Promise<void> { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('followConfirm', { name: user.name || user.username }), + text: i18n.tsx.followConfirm({ name: user.name || user.username }), }); if (canceled) { @@ -42,7 +43,7 @@ if (acct == null) { let promise; if (acct.startsWith('https://')) { - promise = os.api('ap/show', { + promise = misskeyApi('ap/show', { uri: acct, }); promise.then(res => { @@ -60,7 +61,7 @@ if (acct.startsWith('https://')) { } }); } else { - promise = os.api('users/show', Misskey.acct.parse(acct)); + promise = misskeyApi('users/show', Misskey.acct.parse(acct)); promise.then(user => { follow(user); }); diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index 857317c48f..d2fe271b0f 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -47,9 +47,10 @@ import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; import { selectFiles } from '@/scripts/select-file.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -107,7 +108,7 @@ async function del() { } watch(() => props.postId, () => { - init.value = () => props.postId ? os.api('gallery/posts/show', { + init.value = () => props.postId ? misskeyApi('gallery/posts/show', { postId: props.postId, }).then(post => { files.value = post.files ?? []; @@ -121,12 +122,9 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => props.postId ? { - title: i18n.ts.edit, - icon: 'ph-pencil ph-bold ph-lg', -} : { - title: i18n.ts.postToGallery, - icon: 'ph-pencil ph-bold ph-lg', +definePageMetadata(() => ({ + title: props.postId ? i18n.ts.edit : i18n.ts.postToGallery, + icon: 'ph-pencil-simple ph-bold ph-lg', })); </script> diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index 936d9b8393..96979250bd 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="1400"> - <div class="_root"> - <div v-if="tab === 'explore'"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'explore'" key="explore"> <MkFoldableSection class="_margin"> <template #header><i class="ph-clock ph-bold ph-lg"></i>{{ i18n.ts.recentPosts }}</template> <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true"> @@ -26,14 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </MkFoldableSection> </div> - <div v-else-if="tab === 'liked'"> + <div v-else-if="tab === 'liked'" key="liked"> <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> <div :class="$style.items"> <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'my'"> + <div v-else-if="tab === 'my'" key="my"> <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.postToGallery }}</MkA> <MkPagination v-slot="{items}" :pagination="myPostsPagination"> <div :class="$style.items"> @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -51,9 +51,10 @@ import { watch, ref, computed } from 'vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -115,13 +116,13 @@ const headerTabs = computed(() => [{ }, { key: 'my', title: i18n.ts._gallery.my, - icon: 'ph-pencil-line ph-bold ph-lg', + icon: 'ph-pencil-simple-line ph-bold ph-lg', }]); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.gallery, icon: 'ph-images-square ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index 54a8790ef9..1511928d55 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> </div> <div class="other"> - <button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ph-pencil ph-bold ph-lg ti-fw"></i></button> + <button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ph-pencil-simple ph-bold ph-lg ti-fw"></i></button> <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ph-repeat ph-bold ph-lg ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button> <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button> @@ -66,18 +66,19 @@ import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { url } from '@/config.js'; -import { useRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -97,7 +98,7 @@ const otherPostsPagination = { function fetchPost() { post.value = null; - os.api('gallery/posts/show', { + misskeyApi('gallery/posts/show', { postId: props.postId, }).then(_post => { post.value = _post; @@ -155,17 +156,19 @@ function edit() { watch(() => props.postId, fetchPost, { immediate: true }); const headerActions = computed(() => [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.edit, handler: edit, }]); const headerTabs = computed(() => []); -definePageMetadata(computed(() => post.value ? { - title: post.value.title, - avatar: post.value.user, -} : null)); +definePageMetadata(() => ({ + title: post.value ? post.value.title : i18n.ts.gallery, + ...post.value ? { + avatar: post.value.user, + } : {}, +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue new file mode 100644 index 0000000000..2822fbde89 --- /dev/null +++ b/packages/frontend/src/pages/games.vue @@ -0,0 +1,34 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :contentMax="800"> + <div class="_gaps"> + <div class="_panel"> + <MkA to="/bubble-game"> + <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> + </MkA> + </div> + <div class="_panel"> + <MkA to="/reversi"> + <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> + </MkA> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; + +definePageMetadata(() => ({ + title: 'Misskey Games', + icon: 'ph-game-controller ph-bold ph-lg', +})); +</script> diff --git a/packages/frontend/src/pages/install-extentions.vue b/packages/frontend/src/pages/install-extensions.vue index 7e6c75ac99..32f6fbd185 100644 --- a/packages/frontend/src/pages/install-extentions.vue +++ b/packages/frontend/src/pages/install-extensions.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -105,6 +105,7 @@ import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js'; import { parseThemeCode, installTheme } from '@/scripts/install-theme.js'; import { unisonReload } from '@/scripts/unison-reload.js'; @@ -159,7 +160,7 @@ async function fetch() { uiPhase.value = 'error'; return; } - const res = await os.api('fetch-external-resources', { + const res = await misskeyApi('fetch-external-resources', { url: url.value, hash: hash.value, }).catch((err) => { @@ -311,10 +312,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts._externalResourceInstaller.title, icon: 'ph-download ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 683a31c36d..4099e2bac9 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,118 +7,123 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32"> - <div v-if="tab === 'overview'" class="_gaps_m"> - <div class="fnfelxur"> - <img :src="faviconUrl" alt="" class="icon"/> - <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> - </div> - <div style="display: flex; flex-direction: column; gap: 1em;"> - <MkKeyValue :copy="host" oneline> - <template #key>Host</template> - <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.software }}</template> - <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.administrator }}</template> - <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'overview'" key="overview" class="_gaps_m"> + <div class="fnfelxur"> + <img :src="faviconUrl" alt="" class="icon"/> + <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> + </div> + <div style="display: flex; flex-direction: column; gap: 1em;"> + <MkKeyValue :copy="host" oneline> + <template #key>Host</template> + <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> + </MkKeyValue> + </div> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ instance.description }}</template> </MkKeyValue> - </div> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ instance.description }}</template> - </MkKeyValue> - <FormSection v-if="iAmModerator"> - <template #label>Moderation</template> - <div class="_gaps_s"> - <MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> - <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> - <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch> - <MkButton @click="refreshMetadata"><i class="ph-arrows-counter-clockwise ph-bold ph-lg"></i> Refresh metadata</MkButton> - </div> - </FormSection> + <FormSection v-if="iAmModerator"> + <template #label>Moderation</template> + <div class="_gaps_s"> + <MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> + <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> + <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch> + <MkButton @click="refreshMetadata"><i class="ph-arrows-clockwise ph-bold ph-lg"></i> Refresh metadata</MkButton> + <MkTextarea v-model="moderationNote" manualSave> + <template #label>{{ i18n.ts.moderationNote }}</template> + </MkTextarea> + </div> + </FormSection> - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.registeredAt }}</template> - <template #value><MkTime mode="detail" :time="instance.firstRetrievedAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.updatedAt }}</template> - <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> - <template #value><MkTime v-if="instance.latestRequestReceivedAt" mode="detail" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> - </MkKeyValue> - </FormSection> + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.firstRetrievedAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + </FormSection> - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Following (Pub)</template> - <template #value>{{ number(instance.followingCount) }}</template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Followers (Sub)</template> - <template #value>{{ number(instance.followersCount) }}</template> - </MkKeyValue> - </FormSection> + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Following (Pub)</template> + <template #value>{{ number(instance.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Followers (Sub)</template> + <template #value>{{ number(instance.followersCount) }}</template> + </MkKeyValue> + </FormSection> - <FormSection> - <template #label>Well-known resources</template> - <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> - <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> - <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> - <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> - <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> - </FormSection> - </div> - <div v-else-if="tab === 'chart'" class="_gaps_m"> - <div class="cmhjzshl"> - <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> - <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> - <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> - <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> - <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> - <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> - <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> - <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> - <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> - <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> - <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> - </MkSelect> - </div> - <div class="charts"> - <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> - <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> - <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> - <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + <FormSection> + <template #label>Well-known resources</template> + <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> + <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> + <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" key="chart" class="_gaps_m"> + <div class="cmhjzshl"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + <div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + </div> </div> </div> - </div> - <div v-else-if="tab === 'users'" class="_gaps_m"> - <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> - <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`"> - <MkUserCardMini :user="user"/> - </MkA> - </MkPagination> - </div> - <div v-else-if="tab === 'raw'" class="_gaps_m"> - <MkObjectView tall :value="instance"> - </MkObjectView> - </div> + <div v-else-if="tab === 'users'" key="users" class="_gaps_m"> + <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + <div v-else-if="tab === 'raw'" key="raw" class="_gaps_m"> + <MkObjectView tall :value="instance"> + </MkObjectView> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </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'; @@ -130,20 +135,24 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import number from '@/filters/number.js'; import { iAmModerator, iAmAdmin } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; 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; }>(); const tab = ref('overview'); + const chartSrc = ref('instance-requests'); const meta = ref<Misskey.entities.AdminMetaResponse | null>(null); const instance = ref<Misskey.entities.FederationInstance | null>(null); @@ -152,6 +161,7 @@ const isBlocked = ref(false); const isSilenced = ref(false); const isNSFW = ref(false); const faviconUrl = ref<string | null>(null); +const moderationNote = ref(''); const usersPagination = { endpoint: iAmModerator ? 'admin/show-users' : 'users' as const, @@ -164,11 +174,15 @@ 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 os.api('admin/meta'); + meta.value = await misskeyApi('admin/meta'); } - instance.value = await os.api('federation/show-instance', { + instance.value = await misskeyApi('federation/show-instance', { host: props.host, }); suspended.value = instance.value?.isSuspended ?? false; @@ -176,13 +190,14 @@ async function fetch(): Promise<void> { isSilenced.value = instance.value?.isSilenced ?? false; isNSFW.value = instance.value?.isNSFW ?? false; faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview'); + moderationNote.value = instance.value?.moderationNote; } async function toggleBlock(): Promise<void> { if (!meta.value) throw new Error('No meta?'); if (!instance.value) throw new Error('No instance?'); const { host } = instance.value; - await os.api('admin/update-meta', { + await misskeyApi('admin/update-meta', { blockedHosts: isBlocked.value ? meta.value.blockedHosts.concat([host]) : meta.value.blockedHosts.filter(x => x !== host), }); } @@ -192,14 +207,14 @@ async function toggleSilenced(): Promise<void> { if (!instance.value) throw new Error('No instance?'); const { host } = instance.value; const silencedHosts = meta.value.silencedHosts ?? []; - await os.api('admin/update-meta', { + await misskeyApi('admin/update-meta', { silencedHosts: isSilenced.value ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host), }); } async function toggleSuspend(): Promise<void> { if (!instance.value) throw new Error('No instance?'); - await os.api('admin/federation/update-instance', { + await misskeyApi('admin/federation/update-instance', { host: instance.value.host, isSuspended: suspended.value, }); @@ -207,7 +222,7 @@ async function toggleSuspend(): Promise<void> { async function toggleNSFW(): Promise<void> { if (!instance.value) throw new Error('No instance?'); - await os.api('admin/federation/update-instance', { + await misskeyApi('admin/federation/update-instance', { host: instance.value.host, isNSFW: isNSFW.value, }); @@ -215,7 +230,7 @@ async function toggleNSFW(): Promise<void> { function refreshMetadata(): void { if (!instance.value) throw new Error('No instance?'); - os.api('admin/federation/refresh-remote-instance-metadata', { + misskeyApi('admin/federation/refresh-remote-instance-metadata', { host: instance.value.host, }); os.alert({ @@ -251,10 +266,10 @@ const headerTabs = computed(() => [{ icon: 'ph-code ph-bold ph-lg', }]); -definePageMetadata({ +definePageMetadata(() => ({ title: props.host, icon: 'ph-hard-drives ph-bold ph-lg', -}); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 6ac78a2068..b8c006eb77 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,9 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only </MKSpacer> <MkSpacer v-else :contentMax="800"> <div class="_gaps_m" style="text-align: center;"> - <div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div> + <div v-if="resetCycle && inviteLimit">{{ i18n.tsx.inviteLimitResetCycle({ time: resetCycle, limit: inviteLimit }) }}</div> <MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ph-user-plus ph-bold ph-lg"></i> {{ i18n.ts.createInviteCode }}</MkButton> - <div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div> + <div v-if="currentInviteLimit !== null">{{ i18n.tsx.createLimitRemaining({ limit: currentInviteLimit }) }}</div> <MkPagination ref="pagingComponent" :pagination="pagination"> <template #default="{ items }"> @@ -40,6 +40,7 @@ import { computed, ref, shallowRef } from 'vue'; import type * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkButton from '@/components/MkButton.vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; @@ -68,7 +69,7 @@ const resetCycle = computed<null | string>(() => { }); async function create() { - const ticket = await os.api('invite/create'); + const ticket = await misskeyApi('invite/create'); os.alert({ type: 'success', title: i18n.ts.inviteCodeCreated, @@ -87,15 +88,15 @@ function deleted(id: string) { } async function update() { - currentInviteLimit.value = (await os.api('invite/limit')).remaining; + currentInviteLimit.value = (await misskeyApi('invite/limit')).remaining; } update(); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.invite, icon: 'ph-user-plus ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index d6c6685a79..87070d9167 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -37,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -53,12 +54,12 @@ const error = ref(); const users = ref<Misskey.entities.UserDetailed[]>([]); function fetchList(): void { - os.api('users/lists/show', { + misskeyApi('users/lists/show', { listId: props.listId, forPublic: true, }).then(_list => { list.value = _list; - os.api('users/show', { + misskeyApi('users/show', { userIds: list.value.userIds, }).then(_users => { users.value = _users; @@ -100,10 +101,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => list.value ? { - title: list.value.name, +definePageMetadata(() => ({ + title: list.value ? list.value.name : i18n.ts.lists, icon: 'ph-list ph-bold ph-lg', -} : null)); +})); </script> <style lang="scss" module> .main { diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index 2b53b67ab3..4812bfe70f 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,13 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else> <div v-if="_permissions.length > 0"> - <p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p> + <p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p> <p v-else>{{ i18n.ts._auth.permissionAsk }}</p> <ul> - <li v-for="p in _permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> + <li v-for="p in _permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> </ul> </div> - <div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div> + <div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div> <div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div> <div :class="$style.buttons"> <MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton> @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import MkSignin from '@/components/MkSignin.vue'; import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i, login } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -65,7 +65,7 @@ const state = ref<string | null>(null); async function accept(): Promise<void> { state.value = 'waiting'; - await os.api('miauth/gen-token', { + await misskeyApi('miauth/gen-token', { session: props.session, name: props.name, iconUrl: props.icon, @@ -93,10 +93,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: 'MiAuth', icon: 'ph-squares-four ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 79b592dada..f511c48a06 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,8 +14,8 @@ import { ref } from 'vue'; import XAntenna from './editor.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router.js'; import { antennasCache } from '@/cache.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -38,8 +38,8 @@ function onAntennaCreated() { router.push('/my/antennas'); } -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.manageAntennas, icon: 'ph-flying-saucer ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index 851b32527c..a262e932f3 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -13,11 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import XAntenna from './editor.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache } from '@/cache.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -32,12 +32,12 @@ function onAntennaUpdated() { router.push('/my/antennas'); } -os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => { +misskeyApi('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => { antenna.value = antennaResponse; }); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.manageAntennas, icon: 'ph-flying-saucer ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue index 0fc7f862a3..2d29e2d375 100644 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -57,6 +57,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -84,7 +85,7 @@ const userLists = ref<Misskey.entities.UserList[] | null>(null); watch(() => src.value, async () => { if (src.value === 'list' && userLists.value === null) { - userLists.value = await os.api('users/lists/list'); + userLists.value = await misskeyApi('users/lists/list'); } }); @@ -115,11 +116,11 @@ async function saveAntenna() { async function deleteAntenna() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: props.antenna.name }), + text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }), }); if (canceled) return; - await os.api('antennas/delete', { + await misskeyApi('antennas/delete', { antennaId: props.antenna.id, }); @@ -128,7 +129,7 @@ async function deleteAntenna() { } function addUser() { - os.selectUser().then(user => { + os.selectUser({ includeSelf: true }).then(user => { users.value = users.value.trim(); users.value += '\n@' + Misskey.acct.toString(user as any); users.value = users.value.trim(); diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index b46fb7a5d7..a312672f74 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -55,10 +55,10 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.manageAntennas, icon: 'ph-flying-saucer ph-bold ph-lg', -}); +})); onActivated(() => { antennasCache.fetch(); diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index d787e53bb0..f46ea0e0ea 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,20 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> - <div v-if="tab === 'my'" class="_gaps"> - <MkButton primary rounded class="add" @click="create"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.add }}</MkButton> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'my'" key="my" class="_gaps"> + <MkButton primary rounded class="add" @click="create"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.add }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps"> - <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`"> + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`"> + <MkClipPreview :clip="item"/> + </MkA> + </MkPagination> + </div> + <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> + <MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`"> <MkClipPreview :clip="item"/> </MkA> - </MkPagination> - </div> - <div v-else-if="tab === 'favorites'" class="_gaps"> - <MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> - </div> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -32,9 +34,11 @@ import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkClipPreview from '@/components/MkClipPreview.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { clipsCache } from '@/cache.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const pagination = { endpoint: 'clips/list' as const, @@ -43,12 +47,13 @@ const pagination = { }; const tab = ref('my'); + const favorites = ref<Misskey.entities.Clip[] | null>(null); const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); watch(tab, async () => { - favorites.value = await os.api('clips/my-favorites'); + favorites.value = await misskeyApi('clips/my-favorites'); }); async function create() { @@ -99,14 +104,10 @@ const headerTabs = computed(() => [{ icon: 'ph-heart ph-bold ph-lg', }]); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.clip, icon: 'ph-paperclip ph-bold ph-lg', - action: { - icon: 'ph-plus ph-bold ph-lg', - handler: create, - }, -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index 3379cf43d4..f2469be8de 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="items.length > 0" class="_gaps"> <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`"> - <div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i?.policies['userEachUserListsLimit']}` }) }})</span></div> + <div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.tsx.nUsers({ n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }})</span></div> <MkAvatars :userIds="list.userIds" :limit="10"/> </MkA> </div> @@ -37,7 +37,9 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { userListsCache } from '@/cache.js'; import { infoImageUrl } from '@/instance.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const items = computed(() => userListsCache.value.value ?? []); @@ -69,10 +71,10 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.manageLists, icon: 'ph-list ph-bold ph-lg', -}); +})); onActivated(() => { fetch(); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index df9cdb0fce..1b7aa3f938 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder defaultOpen> <template #label>{{ i18n.ts.members }}</template> - <template #caption>{{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i?.policies['userEachUserListsLimit']}` }) }}</template> + <template #caption>{{ i18n.tsx.nUsers({ n: `${list.userIds.length}/${$i.policies['userEachUserListsLimit']}` }) }}</template> <div class="_gaps_s"> <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> @@ -57,7 +57,7 @@ import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { mainRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { userPage } from '@/filters/user.js'; @@ -66,9 +66,12 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import { userListsCache } from '@/cache.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { defaultStore } from '@/store.js'; import MkPagination from '@/components/MkPagination.vue'; +import { mainRouter } from '@/router/main.js'; + +const $i = signinRequired(); const { enableInfiniteScroll, @@ -91,7 +94,7 @@ const membershipsPagination = { }; function fetchList() { - os.api('users/lists/show', { + misskeyApi('users/lists/show', { listId: props.listId, }).then(_list => { list.value = _list; @@ -119,7 +122,7 @@ async function removeUser(item, ev) { danger: true, action: async () => { if (!list.value) return; - os.api('users/lists/pull', { + misskeyApi('users/lists/pull', { listId: list.value.id, userId: item.userId, }).then(() => { @@ -134,7 +137,7 @@ async function showMembershipMenu(item, ev) { text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, icon: item.withReplies ? 'ph-envelope-open ph-bold ph-lg' : 'ph-envelope ph-bold ph-lg', action: async () => { - os.api('users/lists/update-membership', { + misskeyApi('users/lists/update-membership', { listId: list.value.id, userId: item.userId, withReplies: !item.withReplies, @@ -152,7 +155,7 @@ async function deleteList() { if (!list.value) return; const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: list.value.name }), + text: i18n.tsx.removeAreYouSure({ x: list.value.name }), }); if (canceled) return; @@ -183,10 +186,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => list.value ? { - title: list.value.name, +definePageMetadata(() => ({ + title: list.value ? list.value.name : i18n.ts.lists, icon: 'ph-list ph-bold ph-lg', -} : null)); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index e8ba31395e..6f69f9285d 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -31,8 +31,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.notFound, icon: 'ph-warning ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index a98a7bde2c..6ccb5b61e5 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -11,11 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="note"> <div v-if="showNext" class="_margin"> - <MkNotes class="" :pagination="nextPagination" :noGap="true" :disableAutoLoad="true"/> + <MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> </div> <div class="_margin"> - <MkButton v-if="!showNext" :class="$style.loadNext" @click="showNext = true"><i class="ph-caret-up ph-bold ph-lg"></i></MkButton> + <div v-if="!showNext" class="_buttons" :class="$style.loadNext"> + <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ph-caret-up ph-bold ph-lg"></i> <i class="ph-television-simple ph-bold ph-lg"></i></MkButton> + <MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ph-caret-up ph-bold ph-lg"></i> <i class="ph-user ph-bold ph-lg"></i></MkButton> + </div> <div v-if="defaultStore.state.noteDesign === 'misskey'" class="_margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note" :expandAllCws="expandAllCws"/> @@ -32,11 +35,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </div> </div> - <MkButton v-if="!showPrev" :class="$style.loadPrev" @click="showPrev = true"><i class="ph-caret-down ph-bold ph-lg"></i></MkButton> + <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> + <MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ph-caret-down ph-bold ph-lg"></i> <i class="ph-television-simple ph-bold ph-lg"></i></MkButton> + <MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ph-caret-down ph-bold ph-lg"></i> <i class="ph-user ph-bold ph-lg"></i></MkButton> + </div> </div> <div v-if="showPrev" class="_margin"> - <MkNotes class="" :pagination="prevPagination" :noGap="true"/> + <MkNotes class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/> </div> </div> <MkError v-else-if="error" @retry="fetchNote()"/> @@ -50,12 +56,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { Paging } from '@/components/MkPagination.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import MkNotes from '@/components/MkNotes.vue'; import SkNoteDetailed from '@/components/SkNoteDetailed.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; @@ -68,41 +75,60 @@ const props = defineProps<{ const note = ref<null | Misskey.entities.Note>(); const clips = ref<Misskey.entities.Clip[]>(); -const showPrev = ref(false); -const showNext = ref(false); +const showPrev = ref<'user' | 'channel' | false>(false); +const showNext = ref<'user' | 'channel' | false>(false); const expandAllCws = ref(false); const error = ref(); -const prevPagination = { - endpoint: 'users/notes' as const, +const prevUserPagination: Paging = { + endpoint: 'users/notes', limit: 10, params: computed(() => note.value ? ({ userId: note.value.userId, untilId: note.value.id, - }) : null), + }) : undefined), }; -const nextPagination = { +const nextUserPagination: Paging = { reversed: true, - endpoint: 'users/notes' as const, + endpoint: 'users/notes', limit: 10, params: computed(() => note.value ? ({ userId: note.value.userId, sinceId: note.value.id, - }) : null), + }) : undefined), +}; + +const prevChannelPagination: Paging = { + endpoint: 'channels/timeline', + limit: 10, + params: computed(() => note.value ? ({ + channelId: note.value.channelId, + untilId: note.value.id, + }) : undefined), +}; + +const nextChannelPagination: Paging = { + reversed: true, + endpoint: 'channels/timeline', + limit: 10, + params: computed(() => note.value ? ({ + channelId: note.value.channelId, + sinceId: note.value.id, + }) : undefined), }; function fetchNote() { showPrev.value = false; showNext.value = false; note.value = null; - os.api('notes/show', { + misskeyApi('notes/show', { noteId: props.noteId, }).then(res => { note.value = res; // 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く if (note.value.clippedCount > 0 || new Date(note.value.createdAt).getTime() < new Date('2023-10-01').getTime()) { - os.api('notes/clips', { + misskeyApi('notes/clips', { noteId: note.value.id, }).then((_clips) => { clips.value = _clips; @@ -127,16 +153,18 @@ const headerActions = computed(() => note.value ? [ const headerTabs = computed(() => []); -definePageMetadata(computed(() => note.value ? { +definePageMetadata(() => ({ title: i18n.ts.note, - subtitle: dateString(note.value.createdAt), - avatar: note.value.user, - path: `/notes/${note.value.id}`, - share: { - title: i18n.t('noteOf', { user: note.value.user.name }), - text: note.value.text, - }, -} : null)); + ...note.value ? { + subtitle: dateString(note.value.createdAt), + avatar: note.value.user, + path: `/notes/${note.value.id}`, + share: { + title: i18n.tsx.noteOf({ user: note.value.user.name }), + text: note.value.text, + }, + } : {}, +})); </script> <style lang="scss" module> @@ -151,9 +179,7 @@ definePageMetadata(computed(() => note.value ? { .loadNext, .loadPrev { - min-width: 0; - margin: 0 auto; - border-radius: var(--radius-ellipse); + justify-content: center; } .loadNext { @@ -164,6 +190,10 @@ definePageMetadata(computed(() => note.value ? { margin-top: var(--margin); } +.loadButton { + min-width: 0; +} + .note { border-radius: var(--radius); background: var(--panel); diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index f3fadf5c8e..9cbb6323a8 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,15 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="800"> - <div v-if="tab === 'all'"> - <XNotifications class="notifications" :excludeTypes="excludeTypes"/> - </div> - <div v-else-if="tab === 'mentions'"> - <MkNotes :pagination="mentionsPagination"/> - </div> - <div v-else-if="tab === 'directNotes'"> - <MkNotes :pagination="directNotesPagination"/> - </div> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'all'" key="all"> + <XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/> + </div> + <div v-else-if="tab === 'mentions'" key="mention"> + <MkNotes :pagination="mentionsPagination"/> + </div> + <div v-else-if="tab === 'directNotes'" key="directNotes"> + <MkNotes :pagination="directNotesPagination"/> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -24,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; import XNotifications from '@/components/MkNotifications.vue'; import MkNotes from '@/components/MkNotes.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -48,8 +51,8 @@ const directNotesPagination = { function setFilter(ev) { const typeItems = notificationTypes.map(t => ({ - text: i18n.t(`_notification._types.${t}`), - active: includeTypes.value && includeTypes.value.includes(t), + text: i18n.ts._notification._types[t], + active: (includeTypes.value && includeTypes.value.includes(t)) ?? false, action: () => { includeTypes.value = [t]; }, @@ -60,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); } @@ -91,8 +94,15 @@ const headerTabs = computed(() => [{ icon: 'ph-envelope ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.notifications, icon: 'ph-bell ph-bold ph-lg', -}))); +})); </script> + +<style module lang="scss"> +.notifications { + border-radius: var(--radius); + overflow: clip; +} +</style> diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 53b609e0bd..80b6a237ea 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,13 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="800"> <div v-if="$i"> <div v-if="permissions.length > 0"> - <p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p> + <p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p> <p v-else>{{ i18n.ts._auth.permissionAsk }}</p> <ul> - <li v-for="p in permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> + <li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> </ul> </div> - <div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div> + <div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div> <div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div> <form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post"> <input name="login_token" type="hidden" :value="$i.token"/> @@ -51,10 +51,10 @@ function onLogin(res): void { login(res.i); } -definePageMetadata({ +definePageMetadata(() => ({ title: 'OAuth', icon: 'ph-squares-four ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index 459454a9be..2a55c083d1 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,6 +26,7 @@ import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -52,7 +53,7 @@ onMounted(async () => { if (props.modelValue.fileId == null) { await choose(); } else { - os.api('drive/files/show', { + misskeyApi('drive/files/show', { fileId: props.modelValue.fileId, }).then(fileResponse => { file.value = fileResponse; diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index 442558cc2a..978d03c1cd 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,7 +30,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -53,7 +53,7 @@ watch(id, async () => { ...props.modelValue, note: id.value, }); - note.value = await os.api('notes/show', { noteId: id.value }); + note.value = await misskeyApi('notes/show', { noteId: id.value }); }, { immediate: true, }); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue index 885fa55bc9..2e4402085c 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><i class="ph-note ph-bold ph-lg"></i> {{ props.modelValue.title }}</template> <template #func> <button class="_button" @click="rename()"> - <i class="ph-pencil ph-bold ph-lg"></i> + <i class="ph-pencil-simple ph-bold ph-lg"></i> </button> </template> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index 2af4e4e365..e83ad058b4 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue index 52220d36bb..4967e73000 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue index 71fa890f63..7ef66c1c0c 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.container.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 8c4696b04b..92de9d57a5 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -70,11 +70,12 @@ import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { selectFile } from '@/scripts/select-file.js'; -import { mainRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; +import { mainRouter } from '@/router/main.js'; const props = defineProps<{ initPageId?: string; @@ -105,7 +106,7 @@ watch(eyeCatchingImageId, async () => { if (eyeCatchingImageId.value == null) { eyeCatchingImage.value = null; } else { - eyeCatchingImage.value = await os.api('drive/files/show', { + eyeCatchingImage.value = await misskeyApi('drive/files/show', { fileId: eyeCatchingImageId.value, }); } @@ -148,7 +149,7 @@ function save() { if (pageId.value) { options.pageId = pageId.value; - os.api('pages/update', options) + misskeyApi('pages/update', options) .then(page => { currentName.value = name.value.trim(); os.alert({ @@ -157,7 +158,7 @@ function save() { }); }).catch(onError); } else { - os.api('pages/create', options) + misskeyApi('pages/create', options) .then(created => { pageId.value = created.id; currentName.value = name.value.trim(); @@ -173,10 +174,10 @@ function save() { function del() { os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: title.value.trim() }), + text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }), }).then(({ canceled }) => { if (canceled) return; - os.api('pages/delete', { + misskeyApi('pages/delete', { pageId: pageId.value, }).then(() => { os.alert({ @@ -191,7 +192,7 @@ function del() { function duplicate() { title.value = title.value + ' - copy'; name.value = name.value + '-copy'; - os.api('pages/create', getSaveOptions()).then(created => { + misskeyApi('pages/create', getSaveOptions()).then(created => { pageId.value = created.id; currentName.value = name.value.trim(); os.alert({ @@ -235,11 +236,11 @@ function removeEyeCatchingImage() { async function init() { if (props.initPageId) { - page.value = await os.api('pages/show', { + page.value = await misskeyApi('pages/show', { pageId: props.initPageId, }); } else if (props.initPageName && props.initUser) { - page.value = await os.api('pages/show', { + page.value = await misskeyApi('pages/show', { name: props.initPageName, username: props.initUser, }); @@ -282,17 +283,11 @@ const headerTabs = computed(() => [{ icon: 'ph-note ph-bold ph-lg', }]); -definePageMetadata(computed(() => { - let title = i18n.ts._pages.newPage; - if (props.initPageId) { - title = i18n.ts._pages.editPage; - } else if (props.initPageName && props.initUser) { - title = i18n.ts._pages.readPage; - } - return { - title: title, - icon: 'ph-pencil ph-bold ph-lg', - }; +definePageMetadata(() => ({ + title: props.initPageId ? i18n.ts._pages.editPage + : props.initPageName && props.initUser ? i18n.ts._pages.readPage + : i18n.ts._pages.newPage, + icon: 'ph-pencil-simple ph-bold ph-lg', })); </script> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 6b06da9a24..dc47f20bee 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -81,6 +81,7 @@ import * as Misskey from 'misskey-js'; import XPage from '@/components/page/page.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { url } from '@/config.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; @@ -113,7 +114,7 @@ const path = computed(() => props.username + '/' + props.pageName); function fetchPage() { page.value = null; - os.api('pages/show', { + misskeyApi('pages/show', { name: props.pageName, username: props.username, }).then(async _page => { @@ -186,15 +187,17 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => page.value ? { - title: page.value.title || page.value.name, - avatar: page.value.user, - path: `/@${page.value.user.username}/pages/${page.value.name}`, - share: { - title: page.value.title || page.value.name, - text: page.value.summary, - }, -} : null)); +definePageMetadata(() => ({ + title: page.value ? page.value.title || page.value.name : i18n.ts.pages, + ...page.value ? { + avatar: page.value.user, + path: `/@${page.value.user.username}/pages/${page.value.name}`, + share: { + title: page.value.title || page.value.name, + text: page.value.summary, + }, + } : {}, +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index a7ca433ed3..7b4dd83068 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,30 +7,32 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700"> - <div v-if="tab === 'featured'"> - <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> - <div class="_gaps"> - <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> - </div> - </MkPagination> - </div> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div v-if="tab === 'featured'" key="featured"> + <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> + <div class="_gaps"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> + </div> + </MkPagination> + </div> - <div v-else-if="tab === 'my'" class="_gaps"> - <MkButton class="new" @click="create()"><i class="ph-plus ph-bold ph-lg"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="myPagesPagination"> - <div class="_gaps"> - <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> - </div> - </MkPagination> - </div> + <div v-else-if="tab === 'my'" key="my" class="_gaps"> + <MkButton class="new" @click="create()"><i class="ph-plus ph-bold ph-lg"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myPagesPagination"> + <div class="_gaps"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> + </div> + </MkPagination> + </div> - <div v-else-if="tab === 'liked'"> - <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> - <div class="_gaps"> - <MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/> - </div> - </MkPagination> - </div> + <div v-else-if="tab === 'liked'" key="liked"> + <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> + <div class="_gaps"> + <MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/> + </div> + </MkPagination> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -40,9 +42,10 @@ import { computed, ref } from 'vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; -import { useRouter } from '@/router.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -78,15 +81,15 @@ const headerTabs = computed(() => [{ }, { key: 'my', title: i18n.ts._pages.my, - icon: 'ph-pencil-line ph-bold ph-lg', + icon: 'ph-pencil-simple-line ph-bold ph-lg', }, { key: 'liked', title: i18n.ts._pages.liked, icon: 'ph-heart ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.pages, icon: 'ph-note ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index 95aa64f8d3..350c4fea1d 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, computed, ref } from 'vue'; import JSON5 from 'json5'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import FormLink from '@/components/form/link.vue'; @@ -54,7 +55,7 @@ const scope = computed(() => props.path ? props.path.split('/') : []); const keys = ref<any>(null); function fetchKeys() { - os.api('i/registry/keys-with-type', { + misskeyApi('i/registry/keys-with-type', { scope: scope.value, domain: props.domain === '@' ? null : props.domain, }).then(res => { @@ -95,8 +96,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.registry, icon: 'ph-faders ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue index fb3cc4a556..61bf5f4545 100644 --- a/packages/frontend/src/pages/registry.value.vue +++ b/packages/frontend/src/pages/registry.value.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -48,6 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, computed, ref } from 'vue'; import JSON5 from 'json5'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; @@ -68,7 +69,7 @@ const value = ref<any>(null); const valueForEditor = ref<string | null>(null); function fetchValue() { - os.api('i/registry/get-detail', { + misskeyApi('i/registry/get-detail', { scope: scope.value, key: key.value, domain: props.domain === '@' ? null : props.domain, @@ -122,8 +123,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.registry, icon: 'ph-faders ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue index 7d1dd751ab..de0c898187 100644 --- a/packages/frontend/src/pages/registry.vue +++ b/packages/frontend/src/pages/registry.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -26,6 +26,7 @@ import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import JSON5 from 'json5'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import FormLink from '@/components/form/link.vue'; @@ -35,7 +36,7 @@ import MkButton from '@/components/MkButton.vue'; const scopesWithDomain = ref<Misskey.entities.IRegistryScopesWithDomainResponse | null>(null); function fetchScopes() { - os.api('i/registry/scopes-with-domain').then(res => { + misskeyApi('i/registry/scopes-with-domain').then(res => { scopesWithDomain.value = res; }); } @@ -72,8 +73,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.registry, icon: 'ph-faders ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 1aed57724e..8b0b4baa67 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,8 +25,8 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { mainRouter } from '@/router/main.js'; const props = defineProps<{ token?: string; @@ -53,8 +53,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.resetPassword, icon: 'ph-lock ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue new file mode 100644 index 0000000000..c5c3d491fc --- /dev/null +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -0,0 +1,622 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkSpacer :contentMax="500"> + <div :class="$style.root" class="_gaps"> + <div style="display: flex; align-items: center; justify-content: center; gap: 10px;"> + <span>({{ i18n.ts._reversi.black }})</span> + <MkAvatar style="width: 32px; height: 32px;" :user="blackUser" :showIndicator="true"/> + <span> vs </span> + <MkAvatar style="width: 32px; height: 32px;" :user="whiteUser" :showIndicator="true"/> + <span>({{ i18n.ts._reversi.white }})</span> + </div> + + <div style="overflow: clip; line-height: 28px;"> + <div v-if="!iAmPlayer && !game.isEnded && turnUser"> + <Mfm :key="'turn:' + turnUser.id" :text="i18n.tsx._reversi.turnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/> + <MkEllipsis/> + </div> + <div v-if="(logPos !== game.logs.length) && turnUser"> + <Mfm :key="'past-turn-of:' + turnUser.id" :text="i18n.tsx._reversi.pastTurnOf({ name: turnUser.name ?? turnUser.username })" :plain="true" :customEmojis="turnUser.emojis"/> + </div> + <div v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ i18n.ts._reversi.opponentTurn }}<MkEllipsis/><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: opTurnTimerRmain }) }})</span></div> + <div v-if="iAmPlayer && !game.isEnded && isMyTurn"><span style="display: inline-block; font-weight: bold; animation: global-tada 1s linear infinite both;">{{ i18n.ts._reversi.myTurn }}</span><span style="margin-left: 1em; opacity: 0.7;">({{ i18n.tsx.remainingN({ n: myTurnTimerRmain }) }})</span></div> + <div v-if="game.isEnded && logPos == game.logs.length"> + <template v-if="game.winner"> + <Mfm :key="'won'" :text="i18n.tsx._reversi.won({ name: game.winner.name ?? game.winner.username })" :plain="true" :customEmojis="game.winner.emojis"/> + <span v-if="game.surrenderedUserId != null"> ({{ i18n.ts._reversi.surrendered }})</span> + <span v-if="game.timeoutUserId != null"> ({{ i18n.ts._reversi.timeout }})</span> + </template> + <template v-else>{{ i18n.ts._reversi.drawn }}</template> + </div> + </div> + + <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> + </div> + <div style="display: flex;"> + <div v-if="showBoardLabels" :class="$style.labelsY"> + <div v-for="i in game.map.length" :key="i" :class="$style.labelsYLabel">{{ i }}</div> + </div> + <div :class="$style.boardCells" :style="cellsStyle"> + <div + v-for="(stone, i) in engine.board" + :key="i" + v-tooltip="`${String.fromCharCode(65 + engine.posToXy(i)[0])}${engine.posToXy(i)[1] + 1}`" + :class="[$style.boardCell, { + [$style.boardCell_empty]: stone == null, + [$style.boardCell_none]: engine.map[i] === 'null', + [$style.boardCell_isEnded]: game.isEnded, + [$style.boardCell_myTurn]: !game.isEnded && isMyTurn, + [$style.boardCell_can]: turnUser ? engine.canPut(turnUser.id === blackUser.id, i) : null, + [$style.boardCell_prev]: engine.prevPos === i + }]" + @click="putStone(i)" + > + <Transition + :enterActiveClass="$style.transition_flip_enterActive" + :leaveActiveClass="$style.transition_flip_leaveActive" + :enterFromClass="$style.transition_flip_enterFrom" + :leaveToClass="$style.transition_flip_leaveTo" + mode="default" + > + <template v-if="useAvatarAsStone"> + <img v-if="stone === true" :class="$style.boardCellStone" :src="blackUser.avatarUrl ?? undefined"/> + <img v-else-if="stone === false" :class="$style.boardCellStone" :src="whiteUser.avatarUrl ?? undefined"/> + </template> + <template v-else> + <img v-if="stone === true" :class="$style.boardCellStone" src="/client-assets/reversi/stone_b.png"/> + <img v-else-if="stone === false" :class="$style.boardCellStone" src="/client-assets/reversi/stone_w.png"/> + </template> + </Transition> + </div> + </div> + <div v-if="showBoardLabels" :class="$style.labelsY"> + <div v-for="i in game.map.length" :key="i" :class="$style.labelsYLabel">{{ i }}</div> + </div> + </div> + <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> + </div> + </div> + </div> + + <div v-if="game.isEnded" class="_panel _gaps_s" style="padding: 16px;"> + <div>{{ logPos }} / {{ game.logs.length }}</div> + <div v-if="!autoplaying" class="_buttonsCenter"> + <MkButton :disabled="logPos === 0" @click="logPos = 0"><i class="ph-caret-left ph-bold ph-lg"></i></MkButton> + <MkButton :disabled="logPos === 0" @click="logPos--"><i class="ph-caret-left ph-bold ph-lg"></i></MkButton> + <MkButton :disabled="logPos === game.logs.length" @click="logPos++"><i class="ph-caret-right ph-bold ph-lg"></i></MkButton> + <MkButton :disabled="logPos === game.logs.length" @click="logPos = game.logs.length"><i class="ph-caret-right ph-bold ph-lg"></i></MkButton> + </div> + <MkButton style="margin: auto;" :disabled="autoplaying" @click="autoplay()"><i class="ph-play ph-bold ph-lg"></i></MkButton> + </div> + + <div class="_panel" style="padding: 16px;"> + <div> + <b>{{ i18n.tsx._reversi.turnCount({ count: logPos }) }}</b> {{ i18n.ts._reversi.black }}:{{ engine.blackCount }} {{ i18n.ts._reversi.white }}:{{ engine.whiteCount }} {{ i18n.ts._reversi.total }}:{{ engine.blackCount + engine.whiteCount }} + </div> + <div> + <div style="display: flex; align-items: center;"> + <span style="margin-right: 8px;">({{ i18n.ts._reversi.black }})</span> + <MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="blackUser" :showIndicator="true"/> + <MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA> + </div> + <div> vs </div> + <div style="display: flex; align-items: center;"> + <span style="margin-right: 8px;">({{ i18n.ts._reversi.white }})</span> + <MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="whiteUser" :showIndicator="true"/> + <MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA> + </div> + </div> + <div> + <p v-if="game.isLlotheo">{{ i18n.ts._reversi.isLlotheo }}</p> + <p v-if="game.loopedBoard">{{ i18n.ts._reversi.loopedMap }}</p> + <p v-if="game.canPutEverywhere">{{ i18n.ts._reversi.canPutEverywhere }}</p> + </div> + </div> + + <MkFolder> + <template #label>{{ i18n.ts.options }}</template> + <div class="_gaps_s" style="text-align: left;"> + <MkSwitch v-model="showBoardLabels">{{ i18n.ts._reversi.showBoardLabels }}</MkSwitch> + <MkSwitch v-model="useAvatarAsStone">{{ i18n.ts._reversi.useAvatarAsStone }}</MkSwitch> + </div> + </MkFolder> + + <div class="_buttonsCenter"> + <MkButton v-if="!game.isEnded && iAmPlayer" danger @click="surrender">{{ i18n.ts._reversi.surrender }}</MkButton> + <MkButton @click="share">{{ i18n.ts.share }}</MkButton> + </div> + + <MkA v-if="game.isEnded" :to="`/reversi`"> + <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; width: 200px; margin: auto;"/> + </MkA> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as Reversi from 'misskey-reversi'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { deepClone } from '@/scripts/clone.js'; +import { useInterval } from '@/scripts/use-interval.js'; +import { signinRequired } from '@/account.js'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { userPage } from '@/filters/user.js'; +import * as sound from '@/scripts/sound.js'; +import * as os from '@/os.js'; +import { confetti } from '@/scripts/confetti.js'; + +const $i = signinRequired(); + +const props = defineProps<{ + game: Misskey.entities.ReversiGameDetailed; + connection?: Misskey.ChannelConnection<Misskey.Channels['reversiGame']> | null; +}>(); + +const showBoardLabels = ref<boolean>(false); +const useAvatarAsStone = ref<boolean>(true); +const autoplaying = ref<boolean>(false); +// eslint-disable-next-line vue/no-setup-props-destructure +const game = ref<Misskey.entities.ReversiGameDetailed & { logs: Reversi.Serializer.SerializedLog[] }>(deepClone(props.game)); +const logPos = ref<number>(game.value.logs.length); +const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({ + map: game.value.map, + isLlotheo: game.value.isLlotheo, + canPutEverywhere: game.value.canPutEverywhere, + loopedBoard: game.value.loopedBoard, + logs: game.value.logs, +})); + +const iAmPlayer = computed(() => { + return game.value.user1Id === $i.id || game.value.user2Id === $i.id; +}); + +const myColor = computed(() => { + if (!iAmPlayer.value) return null; + if (game.value.user1Id === $i.id && game.value.black === 1) return true; + if (game.value.user2Id === $i.id && game.value.black === 2) return true; + return false; +}); + +const opColor = computed(() => { + if (!iAmPlayer.value) return null; + return !myColor.value; +}); + +const blackUser = computed(() => { + return game.value.black === 1 ? game.value.user1 : game.value.user2; +}); + +const whiteUser = computed(() => { + return game.value.black === 1 ? game.value.user2 : game.value.user1; +}); + +const turnUser = computed(() => { + if (engine.value.turn === true) { + return game.value.black === 1 ? game.value.user1 : game.value.user2; + } else if (engine.value.turn === false) { + return game.value.black === 1 ? game.value.user2 : game.value.user1; + } else { + return null; + } +}); + +const isMyTurn = computed(() => { + if (!iAmPlayer.value) return false; + const u = turnUser.value; + if (u == null) return false; + return u.id === $i.id; +}); + +const cellsStyle = computed(() => { + return { + 'grid-template-rows': `repeat(${game.value.map.length}, 1fr)`, + 'grid-template-columns': `repeat(${game.value.map[0].length}, 1fr)`, + }; +}); + +watch(logPos, (v) => { + if (!game.value.isEnded) return; + engine.value = Reversi.Serializer.restoreGame({ + map: game.value.map, + isLlotheo: game.value.isLlotheo, + canPutEverywhere: game.value.canPutEverywhere, + loopedBoard: game.value.loopedBoard, + logs: game.value.logs.slice(0, v), + }); +}); + +if (game.value.isStarted && !game.value.isEnded) { + useInterval(() => { + if (game.value.isEnded) return; + const crc32 = engine.value.calcCrc32(); + if (_DEV_) console.log('crc32', crc32); + misskeyApi('reversi/verify', { + gameId: game.value.id, + crc32: crc32.toString(), + }).then((res) => { + if (res.desynced) { + if (_DEV_) console.log('resynced'); + restoreGame(res.game!); + } + }); + }, 10000, { immediate: false, afterMounted: true }); +} + +const appliedOps: string[] = []; + +function putStone(pos: number) { + if (game.value.isEnded) return; + if (!iAmPlayer.value) return; + if (!isMyTurn.value) return; + if (!engine.value.canPut(myColor.value!, pos)) return; + + engine.value.putStone(pos); + + triggerRef(engine); + + sound.playUrl('/client-assets/reversi/put.mp3', { + volume: 1, + playbackRate: 1, + }); + + const id = Math.random().toString(36).slice(2); + props.connection!.send('putStone', { + pos: pos, + id, + }); + appliedOps.push(id); + + myTurnTimerRmain.value = game.value.timeLimitForEachTurn; + opTurnTimerRmain.value = game.value.timeLimitForEachTurn; + + checkEnd(); +} + +const myTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn); +const opTurnTimerRmain = ref<number>(game.value.timeLimitForEachTurn); + +const TIMER_INTERVAL_SEC = 3; +if (!props.game.isEnded) { + useInterval(() => { + if (myTurnTimerRmain.value > 0) { + myTurnTimerRmain.value = Math.max(0, myTurnTimerRmain.value - TIMER_INTERVAL_SEC); + } + if (opTurnTimerRmain.value > 0) { + opTurnTimerRmain.value = Math.max(0, opTurnTimerRmain.value - TIMER_INTERVAL_SEC); + } + + if (iAmPlayer.value) { + if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) { + props.connection!.send('claimTimeIsUp', {}); + } + } + }, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true }); +} + +async function onStreamLog(log) { + game.value.logs = Reversi.Serializer.serializeLogs([ + ...Reversi.Serializer.deserializeLogs(game.value.logs), + log, + ]); + + logPos.value++; + + if (log.id == null || !appliedOps.includes(log.id)) { + switch (log.operation) { + case 'put': { + sound.playUrl('/client-assets/reversi/put.mp3', { + volume: 1, + playbackRate: 1, + }); + + if (log.player !== engine.value.turn) { // = desyncが発生している + const _game = await misskeyApi('reversi/show-game', { + gameId: props.game.id, + }); + restoreGame(_game); + return; + } + + engine.value.putStone(log.pos); + triggerRef(engine); + + myTurnTimerRmain.value = game.value.timeLimitForEachTurn; + opTurnTimerRmain.value = game.value.timeLimitForEachTurn; + + checkEnd(); + break; + } + + default: + break; + } + } +} + +function onStreamEnded(x) { + game.value = deepClone(x.game); + + if (game.value.winnerId === $i.id) { + confetti({ + duration: 1000 * 3, + }); + + sound.playUrl('/client-assets/reversi/win.mp3', { + volume: 1, + playbackRate: 1, + }); + } else { + sound.playUrl('/client-assets/reversi/lose.mp3', { + volume: 1, + playbackRate: 1, + }); + } +} + +function checkEnd() { + game.value.isEnded = engine.value.isEnded; + if (game.value.isEnded) { + if (engine.value.winner === true) { + game.value.winnerId = game.value.black === 1 ? game.value.user1Id : game.value.user2Id; + game.value.winner = game.value.black === 1 ? game.value.user1 : game.value.user2; + } else if (engine.value.winner === false) { + game.value.winnerId = game.value.black === 1 ? game.value.user2Id : game.value.user1Id; + game.value.winner = game.value.black === 1 ? game.value.user2 : game.value.user1; + } else { + game.value.winnerId = null; + game.value.winner = null; + } + } +} + +function restoreGame(_game) { + game.value = deepClone(_game); + + engine.value = Reversi.Serializer.restoreGame({ + map: game.value.map, + isLlotheo: game.value.isLlotheo, + canPutEverywhere: game.value.canPutEverywhere, + loopedBoard: game.value.loopedBoard, + logs: game.value.logs, + }); + + logPos.value = game.value.logs.length; + + checkEnd(); +} + +async function surrender() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + misskeyApi('reversi/surrender', { + gameId: game.value.id, + }); +} + +function autoplay() { + autoplaying.value = true; + logPos.value = 0; + const logs = Reversi.Serializer.deserializeLogs(game.value.logs); + + window.setTimeout(() => { + logPos.value = 1; + + let i = 1; + let previousLog = logs[0]; + const tick = () => { + const log = logs[i]; + const time = log.time - previousLog.time; + setTimeout(() => { + i++; + logPos.value++; + previousLog = log; + + if (i < logs.length) { + tick(); + } else { + autoplaying.value = false; + } + }, time); + }; + + tick(); + }, 1000); +} + +function share() { + os.post({ + initialText: `#MisskeyReversi ${location.href}`, + instant: true, + }); +} + +onMounted(() => { + if (props.connection != null) { + props.connection.on('log', onStreamLog); + props.connection.on('ended', onStreamEnded); + } +}); + +onActivated(() => { + if (props.connection != null) { + props.connection.on('log', onStreamLog); + props.connection.on('ended', onStreamEnded); + } +}); + +onDeactivated(() => { + if (props.connection != null) { + props.connection.off('log', onStreamLog); + props.connection.off('ended', onStreamEnded); + } +}); + +onUnmounted(() => { + if (props.connection != null) { + props.connection.off('log', onStreamLog); + props.connection.off('ended', onStreamEnded); + } +}); +</script> + +<style lang="scss" module> +@use "sass:math"; + +.transition_flip_enterActive, +.transition_flip_leaveActive { + backface-visibility: hidden; + transition: opacity 0.5s ease, transform 0.5s ease; +} +.transition_flip_enterFrom { + transform: rotateY(-180deg); + opacity: 0; +} +.transition_flip_leaveTo { + transform: rotateY(180deg); + opacity: 0; +} + +$label-size: 16px; +$gap: 4px; + +.root { + text-align: center; +} + +.boardInner { + padding: 32px; + + background: var(--panel); + box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410; + border-radius: 8px; +} + +@container (max-width: 400px) { + .boardInner { + padding: 16px; + } +} + +.labelsX { + height: $label-size; + padding: 0 $label-size; + display: flex; +} + +.labelsXLabel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + + &:first-child { + margin-left: -(math.div($gap, 2)); + } + + &:last-child { + margin-right: -(math.div($gap, 2)); + } +} + +.labelsY { + width: $label-size; + display: flex; + flex-direction: column; +} + +.labelsYLabel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + &:first-child { + margin-top: -(math.div($gap, 2)); + } + + &:last-child { + margin-bottom: -(math.div($gap, 2)); + } +} + +.boardCells { + flex: 1; + display: grid; + grid-gap: $gap; +} + +.boardCell { + background: transparent; + border-radius: 100%; + aspect-ratio: 1; + transform-style: preserve-3d; + perspective: 150px; + transition: border 0.25s ease, opacity 0.25s ease; + + &.boardCell_empty { + border: solid 2px var(--divider); + } + + &.boardCell_empty.boardCell_can { + border-color: var(--accent); + opacity: 0.5; + } + + &.boardCell_empty.boardCell_myTurn { + border-color: var(--divider); + opacity: 1; + + &.boardCell_can { + border-color: var(--accent); + cursor: pointer; + + &:hover { + background: var(--accent); + } + } + } + + &.boardCell_prev { + box-shadow: 0 0 0 4px var(--accent); + } + + &.boardCell_isEnded { + border-color: var(--divider); + } + + &.boardCell_none { + border-color: transparent !important; + } +} + +.boardCellStone { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + user-select: none; + display: block; + width: 100%; + height: 100%; + border-radius: 100%; +} +</style> diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue new file mode 100644 index 0000000000..93b0972e9c --- /dev/null +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -0,0 +1,298 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <MkSpacer :contentMax="600"> + <div style="text-align: center;"><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></div> + + <div :class="{ [$style.disallow]: isReady }"> + <div class="_gaps" :class="{ [$style.disallowInner]: isReady }"> + <div style="font-size: 1.5em; text-align: center;">{{ i18n.ts._reversi.gameSettings }}</div> + + <template v-if="game.noIrregularRules"> + <div>{{ i18n.ts._reversi.disallowIrregularRules }}</div> + </template> + <template v-else> + <div class="_panel"> + <div style="display: flex; align-items: center; padding: 16px; border-bottom: solid 1px var(--divider);"> + <div>{{ mapName }}</div> + <MkButton style="margin-left: auto;" @click="chooseMap">{{ i18n.ts._reversi.chooseBoard }}</MkButton> + </div> + + <div style="padding: 16px;"> + <div v-if="game.map == null"><i class="ph-dice-five ph-bold ph-lg"></i></div> + <div v-else :class="$style.board" :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.map.join('')" :class="[$style.boardCell, { [$style.boardCellNone]: x == ' ' }]" @click="onMapCellClick(i, x)"> + <i v-if="x === 'b' || x === 'w'" style="pointer-events: none; user-select: none;" :class="x === 'b' ? 'ph-circle-half ph-bold ph-lg' : 'ph-circle ph-bold ph-lg'"></i> + </div> + </div> + </div> + </div> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.blackOrWhite }}</template> + + <MkRadios v-model="game.bw"> + <option value="random">{{ i18n.ts.random }}</option> + <option :value="'1'"> + <I18n :src="i18n.ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user1"/></b> + </template> + </I18n> + </option> + <option :value="'2'"> + <I18n :src="i18n.ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user2"/></b> + </template> + </I18n> + </option> + </MkRadios> + </MkFolder> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template> + <template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template> + + <MkRadios v-model="game.timeLimitForEachTurn"> + <option :value="5">5{{ i18n.ts._time.second }}</option> + <option :value="10">10{{ i18n.ts._time.second }}</option> + <option :value="30">30{{ i18n.ts._time.second }}</option> + <option :value="60">60{{ i18n.ts._time.second }}</option> + <option :value="90">90{{ i18n.ts._time.second }}</option> + <option :value="120">120{{ i18n.ts._time.second }}</option> + <option :value="180">180{{ i18n.ts._time.second }}</option> + <option :value="3600">3600{{ i18n.ts._time.second }}</option> + </MkRadios> + </MkFolder> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.rules }}</template> + + <div class="_gaps_s"> + <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ i18n.ts._reversi.isLlotheo }}</MkSwitch> + <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ i18n.ts._reversi.loopedMap }}</MkSwitch> + <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ i18n.ts._reversi.canPutEverywhere }}</MkSwitch> + </div> + </MkFolder> + </template> + </div> + </div> + </MkSpacer> + <template #footer> + <div :class="$style.footer"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16"> + <div style="text-align: center;" class="_gaps_s"> + <div v-if="opponentHasSettingsChanged" style="color: var(--warn);">{{ i18n.ts._reversi.opponentHasSettingsChanged }}</div> + <div> + <template v-if="isReady && isOpReady">{{ i18n.ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template> + <template v-if="isReady && !isOpReady">{{ i18n.ts._reversi.waitingForOther }}<MkEllipsis/></template> + <template v-if="!isReady && isOpReady">{{ i18n.ts._reversi.waitingForMe }}</template> + <template v-if="!isReady && !isOpReady">{{ i18n.ts._reversi.waitingBoth }}<MkEllipsis/></template> + </div> + <div class="_buttonsCenter"> + <MkButton rounded danger @click="cancel">{{ i18n.ts.cancel }}</MkButton> + <MkButton v-if="!isReady" rounded primary @click="ready">{{ i18n.ts._reversi.ready }}</MkButton> + <MkButton v-if="isReady" rounded @click="unready">{{ i18n.ts._reversi.cancelReady }}</MkButton> + </div> + <div> + <MkSwitch v-model="shareWhenStart">{{ i18n.ts._reversi.shareToTlTheGameWhenStart }}</MkSwitch> + </div> + </div> + </MkSpacer> + </div> + </template> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as Reversi from 'misskey-reversi'; +import { i18n } from '@/i18n.js'; +import { signinRequired } from '@/account.js'; +import { deepClone } from '@/scripts/clone.js'; +import MkButton from '@/components/MkButton.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import * as os from '@/os.js'; +import { MenuItem } from '@/types/menu.js'; +import { useRouter } from '@/router/supplier.js'; + +const $i = signinRequired(); + +const router = useRouter(); + +const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category))); + +const props = defineProps<{ + game: Misskey.entities.ReversiGameDetailed; + connection: Misskey.ChannelConnection; +}>(); + +const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false }); + +const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game)); + +const mapName = computed(() => { + if (game.value.map == null) return 'Random'; + const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join('')); + return found ? found.name! : '-Custom-'; +}); +const isReady = computed(() => { + if (game.value.user1Id === $i.id && game.value.user1Ready) return true; + if (game.value.user2Id === $i.id && game.value.user2Ready) return true; + return false; +}); +const isOpReady = computed(() => { + if (game.value.user1Id !== $i.id && game.value.user1Ready) return true; + if (game.value.user2Id !== $i.id && game.value.user2Ready) return true; + return false; +}); + +const opponentHasSettingsChanged = ref(false); + +watch(() => game.value.bw, () => { + updateSettings('bw'); +}); + +watch(() => game.value.timeLimitForEachTurn, () => { + updateSettings('timeLimitForEachTurn'); +}); + +function chooseMap(ev: MouseEvent) { + const menu: MenuItem[] = []; + + for (const c of mapCategories) { + const maps = Object.values(Reversi.maps).filter(x => x.category === c); + if (maps.length === 0) continue; + if (c != null) { + menu.push({ + type: 'label', + text: c, + }); + } + for (const m of maps) { + menu.push({ + text: m.name!, + action: () => { + game.value.map = m.data; + updateSettings('map'); + }, + }); + } + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + +async function cancel() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + props.connection.send('cancel', {}); + + router.push('/reversi'); +} + +function ready() { + props.connection.send('ready', true); + opponentHasSettingsChanged.value = false; +} + +function unready() { + props.connection.send('ready', false); +} + +function onChangeReadyStates(states) { + game.value.user1Ready = states.user1; + game.value.user2Ready = states.user2; +} + +function updateSettings(key: keyof Misskey.entities.ReversiGameDetailed) { + props.connection.send('updateSettings', { + key: key, + value: game.value[key], + }); +} + +function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof Misskey.entities.ReversiGameDetailed; value: any; }) { + if (userId === $i.id) return; + if (game.value[key] === value) return; + game.value[key] = value; + if (isReady.value) { + opponentHasSettingsChanged.value = true; + unready(); + } +} + +function onMapCellClick(pos: number, pixel: string) { + const x = pos % game.value.map[0].length; + const y = Math.floor(pos / game.value.map[0].length); + const newPixel = + pixel === ' ' ? '-' : + pixel === '-' ? 'b' : + pixel === 'b' ? 'w' : + ' '; + const line = game.value.map[y].split(''); + line[x] = newPixel; + game.value.map[y] = line.join(''); + updateSettings('map'); +} + +props.connection.on('changeReadyStates', onChangeReadyStates); +props.connection.on('updateSettings', onUpdateSettings); + +onUnmounted(() => { + props.connection.off('changeReadyStates', onChangeReadyStates); + props.connection.off('updateSettings', onUpdateSettings); +}); +</script> + +<style lang="scss" module> +.disallow { + cursor: not-allowed; +} +.disallowInner { + pointer-events: none; + user-select: none; + opacity: 0.7; +} + +.board { + display: grid; + grid-gap: 4px; + width: 300px; + height: 300px; + margin: 0 auto; + color: var(--fg); +} + +.boardCell { + display: grid; + place-items: center; + background: transparent; + border: solid 2px var(--divider); + border-radius: 6px; + overflow: clip; + cursor: pointer; +} +.boardCellNone { + border-color: transparent; +} + +.footer { + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + background: var(--acrylicBg); + border-top: solid 0.5px var(--divider); +} +</style> diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue new file mode 100644 index 0000000000..21b7797240 --- /dev/null +++ b/packages/frontend/src/pages/reversi/game.vue @@ -0,0 +1,120 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="game == null || (!game.isEnded && connection == null)"><MkLoading/></div> +<GameSetting v-else-if="!game.isStarted" v-model:shareWhenStart="shareWhenStart" :game="game" :connection="connection!"/> +<GameBoard v-else :game="game" :connection="connection"/> +</template> + +<script lang="ts" setup> +import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import GameSetting from './game.setting.vue'; +import GameBoard from './game.board.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useStream } from '@/stream.js'; +import { signinRequired } from '@/account.js'; +import { useRouter } from '@/router/supplier.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { useInterval } from '@/scripts/use-interval.js'; + +const $i = signinRequired(); + +const router = useRouter(); + +const props = defineProps<{ + gameId: string; +}>(); + +const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null); +const connection = shallowRef<Misskey.ChannelConnection | null>(null); +const shareWhenStart = ref(false); + +watch(() => props.gameId, () => { + fetchGame(); +}); + +function start(_game: Misskey.entities.ReversiGameDetailed) { + if (game.value?.isStarted) return; + + if (shareWhenStart.value) { + misskeyApi('notes/create', { + text: i18n.ts._reversi.iStartedAGame + '\n' + location.href, + visibility: 'home', + }); + } + + game.value = _game; +} + +async function fetchGame() { + const _game = await misskeyApi('reversi/show-game', { + gameId: props.gameId, + }); + + game.value = _game; + shareWhenStart.value = false; + + if (connection.value) { + connection.value.dispose(); + } + if (!game.value.isEnded) { + connection.value = useStream().useChannel('reversiGame', { + gameId: game.value.id, + }); + connection.value.on('started', x => { + start(x.game); + }); + connection.value.on('canceled', x => { + connection.value?.dispose(); + + if (x.userId !== $i.id) { + os.alert({ + type: 'warning', + text: i18n.ts._reversi.gameCanceled, + }); + router.push('/reversi'); + } + }); + } +} + +// 通信を取りこぼした場合の救済 +useInterval(async () => { + if (game.value == null) return; + if (game.value.isStarted) return; + + const _game = await misskeyApi('reversi/show-game', { + gameId: props.gameId, + }); + + if (_game.isStarted) { + start(_game); + } else { + game.value = _game; + } +}, 1000 * 10, { + immediate: false, + afterMounted: true, +}); + +onMounted(() => { + fetchGame(); +}); + +onUnmounted(() => { + if (connection.value) { + connection.value.dispose(); + } +}); + +definePageMetadata(() => ({ + title: 'Reversi', + icon: 'ph-game-controller ph-bold ph-lg', +})); +</script> diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue new file mode 100644 index 0000000000..c863b91834 --- /dev/null +++ b/packages/frontend/src/pages/reversi/index.vue @@ -0,0 +1,353 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkSpacer v-if="!matchingAny && !matchingUser" :contentMax="600"> + <div class="_gaps"> + <div> + <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> + </div> + + <div class="_panel _gaps" style="padding: 16px;"> + <div class="_buttonsCenter"> + <MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton> + <MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton> + </div> + <div style="font-size: 90%; opacity: 0.7; text-align: center;"><i class="ph-music-notes ph-bold ph-lg"></i> {{ i18n.ts.soundWillBePlayed }}</div> + </div> + + <MkFolder v-if="invitations.length > 0" :defaultOpen="true"> + <template #label>{{ i18n.ts.invitations }}</template> + <div class="_gaps_s"> + <button v-for="user in invitations" :key="user.id" v-panel :class="$style.invitation" class="_button" tabindex="-1" @click="accept(user)"> + <MkAvatar style="width: 32px; height: 32px; margin-right: 8px;" :user="user" :showIndicator="true"/> + <span style="margin-right: 8px;"><b><MkUserName :user="user"/></b></span> + <span>@{{ user.username }}</span> + </button> + </div> + </MkFolder> + + <MkFolder v-if="$i" :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.myGames }}</template> + <MkPagination :pagination="myGamesPagination" :disableAutoLoad="true"> + <template #default="{ items }"> + <div :class="$style.gamePreviews"> + <MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`"> + <div :class="$style.gamePreviewPlayers"> + <span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ph-trophy ph-bold ph-lg"></i></span> + <span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ph-x ph-bold ph-lg"></i></span> + <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/> + <span style="margin: 0 1em;">vs</span> + <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/> + <span v-if="g.winnerId === g.user1Id" style="margin-left: 0.75em; visibility: hidden;"><i class="ph-x ph-bold ph-lg"></i></span> + <span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ph-trophy ph-bold ph-lg"></i></span> + </div> + <div :class="$style.gamePreviewFooter"> + <span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span> + <span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span> + <span v-else>{{ i18n.ts._reversi.ended }}</span> + <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/> + </div> + </MkA> + </div> + </template> + </MkPagination> + </MkFolder> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts._reversi.allGames }}</template> + <MkPagination :pagination="gamesPagination" :disableAutoLoad="true"> + <template #default="{ items }"> + <div :class="$style.gamePreviews"> + <MkA v-for="g in items" :key="g.id" v-panel :class="[$style.gamePreview, !g.isStarted && !g.isEnded && $style.gamePreviewWaiting, g.isStarted && !g.isEnded && $style.gamePreviewActive]" tabindex="-1" :to="`/reversi/g/${g.id}`"> + <div :class="$style.gamePreviewPlayers"> + <span v-if="g.winnerId === g.user1Id" style="margin-right: 0.75em; color: var(--accent); font-weight: bold;"><i class="ph-trophy ph-bold ph-lg"></i></span> + <span v-if="g.winnerId === g.user2Id" style="margin-right: 0.75em; visibility: hidden;"><i class="ph-x ph-bold ph-lg"></i></span> + <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user1"/> + <span style="margin: 0 1em;">vs</span> + <MkAvatar :class="$style.gamePreviewPlayersAvatar" :user="g.user2"/> + <span v-if="g.winnerId === g.user1Id" style="margin-left: 0.75em; visibility: hidden;"><i class="ph-x ph-bold ph-lg"></i></span> + <span v-if="g.winnerId === g.user2Id" style="margin-left: 0.75em; color: var(--accent); font-weight: bold;"><i class="ph-trophy ph-bold ph-lg"></i></span> + </div> + <div :class="$style.gamePreviewFooter"> + <span v-if="g.isStarted && !g.isEnded" :class="$style.gamePreviewStatusActive">{{ i18n.ts._reversi.playing }}</span> + <span v-else-if="!g.isEnded" :class="$style.gamePreviewStatusWaiting"><MkEllipsis/></span> + <span v-else>{{ i18n.ts._reversi.ended }}</span> + <MkTime style="margin-left: auto; opacity: 0.7;" :time="g.createdAt"/> + </div> + </MkA> + </div> + </template> + </MkPagination> + </MkFolder> + </div> +</MkSpacer> +<MkSpacer v-else :contentMax="600"> + <div :class="$style.waitingScreen"> + <div v-if="matchingUser" :class="$style.waitingScreenTitle"> + <I18n :src="i18n.ts.waitingFor" tag="span"> + <template #x> + <b><MkUserName :user="matchingUser"/></b> + </template> + </I18n> + <MkEllipsis/> + </div> + <div v-else :class="$style.waitingScreenTitle"> + {{ i18n.ts._reversi.lookingForPlayer }}<MkEllipsis/> + </div> + <div class="cancel"> + <MkButton inline rounded @click="cancelMatching">{{ i18n.ts.cancel }}</MkButton> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useStream } from '@/stream.js'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import { i18n } from '@/i18n.js'; +import { $i } from '@/account.js'; +import MkPagination from '@/components/MkPagination.vue'; +import { useRouter } from '@/router/supplier.js'; +import * as os from '@/os.js'; +import { useInterval } from '@/scripts/use-interval.js'; +import { pleaseLogin } from '@/scripts/please-login.js'; +import * as sound from '@/scripts/sound.js'; + +const myGamesPagination = { + endpoint: 'reversi/games' as const, + limit: 10, + params: { + my: true, + }, +}; + +const gamesPagination = { + endpoint: 'reversi/games' as const, + limit: 10, +}; + +const router = useRouter(); + +if ($i) { + const connection = useStream().useChannel('reversi'); + + connection.on('matched', x => { + if (matchingUser.value != null || matchingAny.value) { + startGame(x.game); + } + }); + + connection.on('invited', invitation => { + if (invitations.value.some(x => x.id === invitation.user.id)) return; + invitations.value.unshift(invitation.user); + }); + + onUnmounted(() => { + connection.dispose(); + }); +} + +const invitations = ref<Misskey.entities.UserLite[]>([]); +const matchingUser = ref<Misskey.entities.UserLite | null>(null); +const matchingAny = ref<boolean>(false); +const noIrregularRules = ref<boolean>(false); + +function startGame(game: Misskey.entities.ReversiGameDetailed) { + matchingUser.value = null; + matchingAny.value = false; + + sound.playUrl('/client-assets/reversi/matched.mp3', { + volume: 1, + playbackRate: 1, + }); + + router.push(`/reversi/g/${game.id}`); +} + +async function matchHeatbeat() { + if (matchingUser.value) { + const res = await misskeyApi('reversi/match', { + userId: matchingUser.value.id, + }); + + if (res != null) { + startGame(res); + } + } else if (matchingAny.value) { + const res = await misskeyApi('reversi/match', { + userId: null, + noIrregularRules: noIrregularRules.value, + }); + + if (res != null) { + startGame(res); + } + } +} + +async function matchUser() { + pleaseLogin(); + + const user = await os.selectUser({ includeSelf: false, localOnly: true }); + if (user == null) return; + + matchingUser.value = user; + + matchHeatbeat(); +} + +function matchAny(ev: MouseEvent) { + pleaseLogin(); + + os.popupMenu([{ + text: i18n.ts._reversi.allowIrregularRules, + action: () => { + noIrregularRules.value = false; + matchingAny.value = true; + matchHeatbeat(); + }, + }, { + text: i18n.ts._reversi.disallowIrregularRules, + action: () => { + noIrregularRules.value = true; + matchingAny.value = true; + matchHeatbeat(); + }, + }], ev.currentTarget ?? ev.target); +} + +function cancelMatching() { + if (matchingUser.value) { + misskeyApi('reversi/cancel-match', { userId: matchingUser.value.id }); + matchingUser.value = null; + } else if (matchingAny.value) { + misskeyApi('reversi/cancel-match', { userId: null }); + matchingAny.value = false; + } +} + +async function accept(user) { + const game = await misskeyApi('reversi/match', { + userId: user.id, + }); + if (game) { + startGame(game); + } +} + +useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true }); + +onMounted(() => { + misskeyApi('reversi/invitations').then(_invitations => { + invitations.value = _invitations; + }); + + window.addEventListener('beforeunload', cancelMatching); +}); + +onDeactivated(() => { + cancelMatching(); +}); + +onUnmounted(() => { + cancelMatching(); +}); + +definePageMetadata(() => ({ + title: 'Reversi', + icon: 'ph-game-controller ph-bold ph-lg', +})); +</script> + +<style lang="scss" module> +@keyframes blink { + 0% { opacity: 1; } + 50% { opacity: 0.2; } +} + +.invitation { + display: flex; + box-sizing: border-box; + width: 100%; + padding: 16px; + line-height: 32px; + text-align: left; +} + +.gamePreviews { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); +} + +.gamePreview { + font-size: 90%; + border-radius: 8px; + overflow: clip; +} + +.gamePreviewActive { + box-shadow: inset 0 0 8px 0px var(--accent); +} + +.gamePreviewWaiting { + box-shadow: inset 0 0 8px 0px var(--warn); +} + +.gamePreviewPlayers { + text-align: center; + padding: 16px; + line-height: 32px; +} + +.gamePreviewPlayersAvatar { + width: 32px; + height: 32px; + + &:first-child { + margin-right: 8px; + } + + &:last-child { + margin-left: 8px; + } +} + +.gamePreviewFooter { + display: flex; + align-items: baseline; + border-top: solid 0.5px var(--divider); + padding: 6px 10px; + font-size: 0.9em; +} + +.gamePreviewStatusActive { + color: var(--accent); + font-weight: bold; + animation: blink 2s infinite; +} + +.gamePreviewStatusWaiting { + color: var(--warn); + font-weight: bold; + animation: blink 2s infinite; +} + +.waitingScreen { + text-align: center; +} + +.waitingScreenTitle { + font-size: 1.5em; + margin-bottom: 16px; + margin-top: 32px; +} +</style> diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 6dce4f187d..8621b61eeb 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkUserList from '@/components/MkUserList.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; @@ -59,7 +59,7 @@ const error = ref(); const visible = ref(false); watch(() => props.role, () => { - os.api('roles/show', { + misskeyApi('roles/show', { roleId: props.role, }).then(res => { role.value = res; @@ -89,14 +89,14 @@ const headerTabs = computed(() => [{ title: i18n.ts.users, }, { key: 'timeline', - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', title: i18n.ts.timeline, }]); -definePageMetadata(computed(() => ({ - title: role.value?.name, +definePageMetadata(() => ({ + title: role.value ? role.value.name : i18n.ts.role, icon: 'ph-seal-check ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index ccda5bb8ac..fb3657cdc9 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,7 +44,7 @@ import { Interpreter, Parser, utils } from '@syuilo/aiscript'; import MkContainer from '@/components/MkContainer.vue'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; -import { createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import * as os from '@/os.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; @@ -86,19 +86,7 @@ async function run() { root.value = _root.value; }), }), { - in: (q) => { - return new Promise(ok => { - os.inputText({ - title: q, - }).then(({ canceled, result: a }) => { - if (canceled) { - ok(''); - } else { - ok(a); - } - }); - }); - }, + in: aiScriptReadline, out: (value) => { if (value.type === 'str' && value.value.toLowerCase().replace(',', '').includes('hello world')) { claimAchievement('outputHelloWorldOnScratchpad'); @@ -164,10 +152,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.scratchpad, icon: 'ph-terminal-window ph-bold ph-lg-2', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index 405db06758..33de0d72cf 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="audio">Audio</option> </MkSelect> - <MkFolder> + <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.specifyUser }}</template> <template v-if="user" #suffix>@{{ user.username }}</template> @@ -58,9 +58,10 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { useRouter } from '@/router.js'; import MkFolder from '@/components/MkFolder.vue'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -74,7 +75,7 @@ const order = ref(false); const filetype = ref(null); function selectUser() { - os.selectUser().then(_user => { + os.selectUser({ includeSelf: true }).then(_user => { user.value = _user; }); } @@ -85,7 +86,7 @@ async function search() { if (query == null || query === '') return; if (query.startsWith('https://')) { - const promise = os.api('ap/show', { + const promise = misskeyApi('ap/show', { uri: query, }); diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 596f4da711..dad9cd910a 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -33,7 +33,8 @@ import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -48,7 +49,7 @@ async function search() { if (query == null || query === '') return; if (query.startsWith('https://')) { - const promise = os.api('ap/show', { + const promise = misskeyApi('ap/show', { uri: query, }); diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index acc291c73e..fe56297c65 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,18 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="tab === 'note'" :contentMax="800"> - <div v-if="notesSearchAvailable"> - <XNote/> - </div> - <div v-else> - <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> - </div> - </MkSpacer> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSpacer v-if="tab === 'note'" key="note" :contentMax="800"> + <div v-if="notesSearchAvailable"> + <XNote/> + </div> + <div v-else> + <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> + </div> + </MkSpacer> - <MkSpacer v-else-if="tab === 'user'" :contentMax="800"> - <XUser/> - </MkSpacer> + <MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800"> + <XUser/> + </MkSpacer> + </MkHorizontalSwipe> </MkStickyContainer> </template> @@ -29,6 +31,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; import { instance } from '@/instance.js'; import MkInfo from '@/components/MkInfo.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const XNote = defineAsyncComponent(() => import('./search.note.vue')); const XUser = defineAsyncComponent(() => import('./search.user.vue')); @@ -42,15 +45,15 @@ const headerActions = computed(() => []); const headerTabs = computed(() => [{ key: 'note', title: i18n.ts.notes, - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', }, { key: 'user', title: i18n.ts.users, icon: 'ph-users ph-bold ph-lg', }]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.search, icon: 'ph-magnifying-glass ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 9a2a98ad89..13f475c2f2 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -110,7 +110,9 @@ import * as os from '@/os.js'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; import { confetti } from '@/scripts/confetti.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); defineProps<{ twoFactorData: { @@ -151,7 +153,7 @@ function downloadBackupCodes() { const txtBlob = new Blob([backupCodes.value.join('\n')], { type: 'text/plain' }); const dummya = document.createElement('a'); dummya.href = URL.createObjectURL(txtBlob); - dummya.download = `${$i?.username}-2fa-backup-codes.txt`; + dummya.download = `${$i.username}-2fa-backup-codes.txt`; dummya.click(); } } diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index 09421ba2c2..ba85a43084 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -80,9 +80,11 @@ import MkSwitch from '@/components/MkSwitch.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { i18n } from '@/i18n.js'; +const $i = signinRequired(); + // メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 withDefaults(defineProps<{ @@ -91,7 +93,7 @@ withDefaults(defineProps<{ first: false, }); -const usePasswordLessLogin = computed(() => $i?.usePasswordLessLogin ?? false); +const usePasswordLessLogin = computed(() => $i.usePasswordLessLogin ?? false); async function registerTOTP(): Promise<void> { const auth = await os.authenticateDialog(); @@ -139,7 +141,7 @@ async function unregisterKey(key) { const confirm = await os.confirm({ type: 'question', title: i18n.ts._2fa.removeKey, - text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }), + text: i18n.tsx._2fa.removeKeyConfirm({ name: key.name }), }); if (confirm.canceled) return; diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 697ce27f2f..f5effbd68b 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -24,6 +24,7 @@ import type * as Misskey from 'misskey-js'; import FormSuspense from '@/components/form/suspense.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -36,7 +37,7 @@ const init = async () => { getAccounts().then(accounts => { storedAccounts.value = accounts.filter(x => x.id !== $i!.id); - return os.api('users/show', { + return misskeyApi('users/show', { userIds: storedAccounts.value.map(x => x.id), }); }).then(response => { @@ -105,10 +106,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.accounts, icon: 'ph-users ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue index ca38bd2e3d..f8f340d602 100644 --- a/packages/frontend/src/pages/settings/api.vue +++ b/packages/frontend/src/pages/settings/api.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,6 +16,7 @@ import { defineAsyncComponent, ref, computed } from 'vue'; import FormLink from '@/components/form/link.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -25,7 +26,7 @@ function generateToken() { os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { done: async result => { const { name, permissions } = result; - const { token } = await os.api('miauth/gen-token', { + const { token } = await misskeyApi('miauth/gen-token', { session: null, name: name, permission: permissions, @@ -44,8 +45,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: 'API', icon: 'ph-webhooks-logo ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index f492dc6d31..abdb5d1cdd 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only <details> <summary>{{ i18n.ts.details }}</summary> <ul> - <li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> + <li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li> </ul> </details> <div> @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import FormPagination from '@/components/MkPagination.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; @@ -66,7 +66,7 @@ const pagination = { }; function revoke(token) { - os.api('i/revoke-token', { tokenId: token.id }).then(() => { + misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => { list.value.reload(); }); } @@ -75,10 +75,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.installedApps, icon: 'ph-plug ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue index 2bf261abd9..1b731ff624 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const props = defineProps<{ active?: boolean; diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue index a46a92d1c6..327e0ef723 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,7 +51,9 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; import MkRange from '@/components/MkRange.vue'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const props = defineProps<{ usingIndex: number | null; diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue index 976f6aa68c..a60d7209cf 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> <div v-if="!loading" class="_gaps"> - <MkInfo>{{ i18n.t('_profile.avatarDecorationMax', { max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo> + <MkInfo>{{ i18n.tsx._profile.avatarDecorationMax({ max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.tsx.remainingN({ n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo> <MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration/> @@ -50,15 +50,18 @@ import * as Misskey from 'misskey-js'; import XDecoration from './avatar-decoration.decoration.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import MkInfo from '@/components/MkInfo.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +const $i = signinRequired(); + const loading = ref(true); const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]); -os.api('get-avatar-decorations').then(_avatarDecorations => { +misskeyApi('get-avatar-decorations').then(_avatarDecorations => { avatarDecorations.value = _avatarDecorations; loading.value = false; }); @@ -125,10 +128,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.avatarDecorations, icon: 'ph-sparkle ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index 00a1fca856..59733e896f 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -45,8 +45,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.customCss, icon: 'ph-code ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index 32acd5e7a6..81ae9bc2f7 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -36,8 +36,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.deck, icon: 'ph-text-columns ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 601479b73c..fef12fee06 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -51,6 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, watch } from 'vue'; import tinycolor from 'tinycolor2'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkPagination from '@/components/MkPagination.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import { i18n } from '@/i18n.js'; @@ -94,7 +95,7 @@ watch(sortModeSelect, () => { function fetchDriveInfo(): void { fetching.value = true; - os.api('drive').then(info => { + misskeyApi('drive').then(info => { capacity.value = info.capacity; usage.value = info.usage; fetching.value = false; @@ -116,10 +117,10 @@ function onContextMenu(ev: MouseEvent, file): void { os.contextMenu(getDriveFileMenu(file), ev); } -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.drivecleaner, icon: 'ph-trash ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 166e49ac54..4185a1c855 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -62,12 +62,15 @@ import FormSection from '@/components/form/section.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import bytes from '@/filters/bytes.js'; import { defaultStore } from '@/store.js'; import MkChart from '@/components/MkChart.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); const fetching = ref(true); const usage = ref<number | null>(null); @@ -76,6 +79,7 @@ const uploadFolder = ref<Misskey.entities.DriveFolder | null>(null); const alwaysMarkNsfw = ref($i.alwaysMarkNsfw); const meterStyle = computed(() => { + if (!capacity.value || !usage.value) return {}; return { width: `${usage.value / capacity.value * 100}%`, background: tinycolor({ @@ -88,14 +92,14 @@ const meterStyle = computed(() => { const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); -os.api('drive').then(info => { +misskeyApi('drive').then(info => { capacity.value = info.capacity; usage.value = info.usage; fetching.value = false; }); if (defaultStore.state.uploadFolder) { - os.api('drive/folders/show', { + misskeyApi('drive/folders/show', { folderId: defaultStore.state.uploadFolder, }).then(response => { uploadFolder.value = response; @@ -104,10 +108,10 @@ 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 os.api('drive/folders/show', { + uploadFolder.value = await misskeyApi('drive/folders/show', { folderId: defaultStore.state.uploadFolder, }); } else { @@ -117,7 +121,7 @@ function chooseUploadFolder() { } function saveProfile() { - os.api('i/update', { + misskeyApi('i/update', { alwaysMarkNsfw: !!alwaysMarkNsfw.value, }).catch(err => { os.alert({ @@ -133,10 +137,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.drive, icon: 'ph-cloud ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index 003501f45a..938abb0651 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -54,15 +54,18 @@ import MkInfo from '@/components/MkInfo.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { signinRequired } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { instance } from '@/instance.js'; -const emailAddress = ref($i!.email); +const $i = signinRequired(); + +const emailAddress = ref($i.email); const onChangeReceiveAnnouncementEmail = (v) => { - os.api('i/update', { + misskeyApi('i/update', { receiveAnnouncementEmail: v, }); }; @@ -78,14 +81,14 @@ async function saveEmailAddress() { }); } -const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention')); -const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply')); -const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote')); -const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow')); -const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest')); +const emailNotification_mention = ref($i.emailNotificationTypes.includes('mention')); +const emailNotification_reply = ref($i.emailNotificationTypes.includes('reply')); +const emailNotification_quote = ref($i.emailNotificationTypes.includes('quote')); +const emailNotification_follow = ref($i.emailNotificationTypes.includes('follow')); +const emailNotification_receiveFollowRequest = ref($i.emailNotificationTypes.includes('receiveFollowRequest')); const saveNotificationSettings = () => { - os.api('i/update', { + misskeyApi('i/update', { emailNotificationTypes: [ ...[emailNotification_mention.value ? 'mention' : null], ...[emailNotification_reply.value ? 'reply' : null], @@ -110,8 +113,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.email, icon: 'ph-envelope ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue index 40bb823ac6..e9936ca5f2 100644 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ b/packages/frontend/src/pages/settings/emoji-picker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #item="{element}"> <button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/> + <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> <MkEmoji v-else :emoji="element" :normal="true"/> </button> </template> @@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #item="{element}"> <button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/> + <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> <MkEmoji v-else :emoji="element" :normal="true"/> </button> </template> @@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FromSlot> <template #label>{{ i18n.ts.defaultLike }}</template> - <MkCustomEmoji v-if="like && like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="like" :normal="true" :noStyle="true"/> + <MkCustomEmoji v-if="like && like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="like" :normal="true" :noStyle="true"/> <MkEmoji v-else-if="like && !like.startsWith(':')" :emoji="like" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/> <span v-else-if="!like">{{ i18n.ts.notSet }}</span> <div class="_buttons" style="padding-top: 8px;"> @@ -172,7 +172,7 @@ const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev); const setDefaultEmoji = () => setDefault(pinnedEmojis); function previewReaction(ev: MouseEvent) { - reactionPicker.show(getHTMLElement(ev)); + reactionPicker.show(getHTMLElement(ev), null); } function previewEmoji(ev: MouseEvent) { @@ -228,7 +228,7 @@ async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) { os.pickEmoji(getHTMLElement(ev), { showPinned: false, }).then(it => { - const emoji = it as string; + const emoji = it; if (!itemsRef.value.includes(emoji)) { itemsRef.value.push(emoji); } @@ -276,10 +276,10 @@ watch(pinnedEmojis, () => { deep: true, }); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.emojiPicker, icon: 'ph-smiley ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 8eacdd32e6..1e4e815d5d 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,6 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkSelect> + <MkRadios v-model="hemisphere"> + <template #label>{{ i18n.ts.hemisphere }}</template> + <option value="N">{{ i18n.ts._hemisphere.N }}</option> + <option value="S">{{ i18n.ts._hemisphere.S }}</option> + <template #caption>{{ i18n.ts._hemisphere.caption }}</template> + </MkRadios> + <MkRadios v-model="overridedDeviceKind"> <template #label>{{ i18n.ts.overridedDeviceKind }}</template> <option :value="null">{{ i18n.ts.auto }}</option> @@ -87,9 +94,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRadios v-model="mediaListWithOneImageAppearance"> <template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template> <option value="expand">{{ i18n.ts.default }}</option> - <option value="16_9">{{ i18n.t('limitTo', { x: '16:9' }) }}</option> - <option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option> - <option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option> + <option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option> + <option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option> + <option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option> </MkRadios> <MkRange v-model="numberOfReplies" :min="2" :max="20" :step="1" easing> @@ -138,6 +145,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch> <MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch> <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> + <MkSwitch v-model="oneko">{{ i18n.ts.oneko }}</MkSwitch> <MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch> </div> <div> @@ -146,6 +154,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="native">{{ i18n.ts.native }}</option> <option value="fluentEmoji">Fluent Emoji</option> <option value="twemoji">Twemoji</option> + <option value="tossface">Tossface</option> </MkRadios> <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> </div> @@ -171,6 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div class="_gaps_s"> + <MkSwitch v-model="warnMissingAltText">{{ i18n.ts.warnForMissingAltText }}</MkSwitch> <MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> <MkSwitch v-model="useReactionPickerForContextMenu">{{ i18n.ts.useReactionPickerForContextMenu }}</MkSwitch> <MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> @@ -178,6 +188,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="clickToOpen">{{ i18n.ts.clickToOpen }}</MkSwitch> <MkSwitch v-model="showBots">{{ i18n.ts.showBots }}</MkSwitch> <MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> + <MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch> </div> <MkSelect v-model="serverDisconnectedBehavior"> <template #label>{{ i18n.ts.whenServerDisconnected }}</template> @@ -191,6 +202,22 @@ SPDX-License-Identifier: AGPL-3.0-only </MkRange> <MkFolder> + <template #label>{{ i18n.ts.boostSettings }}</template> + <div class="_gaps_m"> + <MkSwitch v-model="showVisibilitySelectorOnBoost"> + {{ i18n.ts.showVisibilitySelectorOnBoost }} + <template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template> + </MkSwitch> + <MkSelect v-model="visibilityOnBoost"> + <template #label>{{ i18n.ts.visibilityOnBoost }}</template> + <option value="public">{{ i18n.ts._visibility['public'] }}</option> + <option value="home">{{ i18n.ts._visibility['home'] }}</option> + <option value="followers">{{ i18n.ts._visibility['followers'] }}</option> + </MkSelect> + </div> + </MkFolder> + + <MkFolder> <template #label>{{ i18n.ts.dataSaver }}</template> <div class="_gaps_m"> @@ -229,9 +256,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <MkFolder> <template #label>{{ i18n.ts.additionalEmojiDictionary }}</template> - <div v-for="lang in emojiIndexLangs" class="_buttons"> - <MkButton @click="downloadEmojiIndex(lang)"><i class="ph-download ph-bold ph-lg"></i> {{ lang }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> - <MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.remove }}</MkButton> + <div class="_buttons"> + <template v-for="lang in emojiIndexLangs" :key="lang"> + <MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton> + <MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ph-download ph-bold ph-lg"></i> {{ getEmojiIndexLangName(lang) }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> + </template> </div> </MkFolder> <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink> @@ -257,6 +286,7 @@ import MkInfo from '@/components/MkInfo.vue'; import { langs } from '@/config.js'; import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -280,6 +310,7 @@ async function reloadAsk() { unisonReload(); } +const hemisphere = computed(defaultStore.makeGetterSetter('hemisphere')); const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); @@ -302,9 +333,11 @@ const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds')); +const oneko = computed(defaultStore.makeGetterSetter('oneko')); const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); +const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel')); @@ -326,6 +359,9 @@ const noteDesign = computed(defaultStore.makeGetterSetter('noteDesign')); const uncollapseCW = computed(defaultStore.makeGetterSetter('uncollapseCW')); const expandLongNote = computed(defaultStore.makeGetterSetter('expandLongNote')); const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect')); +const showVisibilitySelectorOnBoost = computed(defaultStore.makeGetterSetter('showVisibilitySelectorOnBoost')); +const visibilityOnBoost = computed(defaultStore.makeGetterSetter('visibilityOnBoost')); +const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -364,6 +400,7 @@ watch(noteDesign, async (newval) => { }); watch([ + hemisphere, lang, fontSize, cornerRadius, @@ -381,19 +418,35 @@ watch([ keepScreenOn, disableStreamingTimeline, enableSeasonalScreenEffect, + showVisibilitySelectorOnBoost, + visibilityOnBoost, ], async () => { await reloadAsk(); }); -const emojiIndexLangs = ['en-US']; +const emojiIndexLangs = ['en-US', 'ja-JP', 'ja-JP_hira'] as const; -function downloadEmojiIndex(lang: string) { +function getEmojiIndexLangName(targetLang: typeof emojiIndexLangs[number]) { + if (langs.find(x => x[0] === targetLang)) { + return langs.find(x => x[0] === targetLang)![1]; + } else { + // 絵文字辞書限定の言語定義 + switch (targetLang) { + case 'ja-JP_hira': return 'ひらがな'; + default: return targetLang; + } + } +} + +function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { async function main() { const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; function download() { switch (lang) { case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default); + case 'ja-JP': return import('../../unicode-emoji-indexes/ja-JP.json').then(x => x.default); + case 'ja-JP_hira': return import('../../unicode-emoji-indexes/ja-JP_hira.json').then(x => x.default); default: throw new Error('unrecognized lang: ' + lang); } } @@ -416,7 +469,7 @@ function removeEmojiIndex(lang: string) { } async function setPinnedList() { - const lists = await os.api('users/lists/list'); + const lists = await misskeyApi('users/lists/list'); const { canceled, result: list } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ @@ -485,8 +538,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.general, icon: 'ph-faders ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue index 7ca1faf406..87bde70fc2 100644 --- a/packages/frontend/src/pages/settings/import-export.vue +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -1,12 +1,12 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div class="_gaps_m"> <FormSection first> - <template #label><i class="ph-pencil ph-bold ph-lg"></i> {{ i18n.ts._exportOrImport.allNotes }}</template> + <template #label><i class="ph-pencil-simple ph-bold ph-lg"></i> {{ i18n.ts._exportOrImport.allNotes }}</template> <div class="_gaps_s"> <MkFolder> <template #label>{{ i18n.ts.export }}</template> @@ -37,6 +37,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </FormSection> <FormSection> + <template #label><i class="ph-paperclip ph-bold ph-lg"></i> {{ i18n.ts._exportOrImport.clips }}</template> + <MkFolder> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ph-download ph-bold ph-lg"></i></template> + <MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ph-download ph-bold ph-lg"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </FormSection> + <FormSection> <template #label><i class="ph-users ph-bold ph-lg"></i> {{ i18n.ts._exportOrImport.followingList }}</template> <div class="_gaps_s"> <MkFolder> @@ -133,11 +141,12 @@ import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { selectFile } from '@/scripts/select-file.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; -import { defaultStore } from "@/store.js"; +import { defaultStore } from '@/store.js'; const excludeMutingUsers = ref(false); const excludeInactiveUsers = ref(false); @@ -166,15 +175,19 @@ const onError = (ev) => { }; const exportNotes = () => { - os.api('i/export-notes', {}).then(onExportSuccess).catch(onError); + misskeyApi('i/export-notes', {}).then(onExportSuccess).catch(onError); }; const exportFavorites = () => { - os.api('i/export-favorites', {}).then(onExportSuccess).catch(onError); + misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); +}; + +const exportClips = () => { + misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); }; const exportFollowing = () => { - os.api('i/export-following', { + misskeyApi('i/export-following', { excludeMuting: excludeMutingUsers.value, excludeInactive: excludeInactiveUsers.value, }) @@ -182,24 +195,24 @@ const exportFollowing = () => { }; const exportBlocking = () => { - os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError); + misskeyApi('i/export-blocking', {}).then(onExportSuccess).catch(onError); }; const exportUserLists = () => { - os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError); + misskeyApi('i/export-user-lists', {}).then(onExportSuccess).catch(onError); }; const exportMuting = () => { - os.api('i/export-mute', {}).then(onExportSuccess).catch(onError); + misskeyApi('i/export-mute', {}).then(onExportSuccess).catch(onError); }; const exportAntennas = () => { - os.api('i/export-antennas', {}).then(onExportSuccess).catch(onError); + misskeyApi('i/export-antennas', {}).then(onExportSuccess).catch(onError); }; const importFollowing = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('i/import-following', { + misskeyApi('i/import-following', { fileId: file.id, withReplies: withReplies.value, }).then(onImportSuccess).catch(onError); @@ -207,7 +220,7 @@ const importFollowing = async (ev) => { const importNotes = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('i/import-notes', { + misskeyApi('i/import-notes', { fileId: file.id, type: noteType.value, }).then(onImportSuccess).catch(onError); @@ -215,32 +228,32 @@ const importNotes = async (ev) => { const importUserLists = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); + misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); }; const importMuting = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); + misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); }; const importBlocking = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); + misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); }; const importAntennas = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); - os.api('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); + misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); }; const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.importAndExport, icon: 'ph-package ph-bold ph-lg', -}); +})); </script> <style module> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 96575e097b..35fb1a03f4 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,16 +27,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { ComputedRef, Ref, computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; +import { computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; import { i18n } from '@/i18n.js'; import MkInfo from '@/components/MkInfo.vue'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; import { signout, $i } from '@/account.js'; import { clearCache } from '@/scripts/clear-cache.js'; import { instance } from '@/instance.js'; -import { useRouter } from '@/router.js'; -import { PageMetadata, definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import * as os from '@/os.js'; +import { useRouter } from '@/router/supplier.js'; const indexInfo = { title: i18n.ts.settings, @@ -45,7 +45,7 @@ const indexInfo = { }; const INFO = ref(indexInfo); const el = shallowRef<HTMLElement | null>(null); -const childInfo: Ref<ComputedRef<PageMetadata> | null> = ref(null); +const childInfo = ref<null | PageMetadata>(null); const router = useRouter(); @@ -230,20 +230,22 @@ watch(router.currentRef, (to) => { const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); -provideMetadataReceiver((info) => { +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); if (info == null) { childInfo.value = null; } else { childInfo.value = info; - INFO.value.needWideArea = info.value.needWideArea ?? undefined; + INFO.value.needWideArea = info.needWideArea ?? undefined; } }); +provideReactiveMetadata(INFO); const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(INFO); +definePageMetadata(() => INFO.value); // w 890 // h 700 </script> diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue index 3b47189eb4..12f29e2ff8 100644 --- a/packages/frontend/src/pages/settings/migration.vue +++ b/packages/frontend/src/pages/settings/migration.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <MkInput v-for="(_, i) in accountAliases" v-model="accountAliases[i]"> <template #prefix><i class="ph-airplane-landing ph-bold ph-lg"></i></template> - <template #label>{{ i18n.t('_accountMigration.moveFromLabel', { n: i + 1 }) }}</template> + <template #label>{{ i18n.tsx._accountMigration.moveFromLabel({ n: i + 1 }) }}</template> </MkInput> </div> </div> </MkFolder> - <MkFolder :defaultOpen="!!$i?.movedTo"> + <MkFolder :defaultOpen="!!$i.movedTo"> <template #icon><i class="ph-airplane-takeoff ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._accountMigration.moveTo }}</template> @@ -66,24 +66,27 @@ import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { unisonReload } from '@/scripts/unison-reload.js'; +const $i = signinRequired(); + const moveToAccount = ref(''); const movedTo = ref<Misskey.entities.UserDetailed>(); const accountAliases = ref(['']); async function init() { - if ($i?.movedTo) { - movedTo.value = await os.api('users/show', { userId: $i.movedTo }); + if ($i.movedTo) { + movedTo.value = await misskeyApi('users/show', { userId: $i.movedTo }); } else { moveToAccount.value = ''; } - if ($i?.alsoKnownAs && $i.alsoKnownAs.length > 0) { - const alsoKnownAs = await os.api('users/show', { userIds: $i.alsoKnownAs }); + if ($i.alsoKnownAs && $i.alsoKnownAs.length > 0) { + const alsoKnownAs = await misskeyApi('users/show', { userIds: $i.alsoKnownAs }); accountAliases.value = (alsoKnownAs && alsoKnownAs.length > 0) ? alsoKnownAs.map(user => `@${Misskey.acct.toString(user)}`) : ['']; } else { accountAliases.value = ['']; @@ -94,7 +97,7 @@ async function move(): Promise<void> { const account = moveToAccount.value; const confirm = await os.confirm({ type: 'warning', - text: i18n.t('_accountMigration.migrationConfirm', { account }), + text: i18n.tsx._accountMigration.migrationConfirm({ account }), }); if (confirm.canceled) return; await os.apiWithDialog('i/move', { @@ -118,10 +121,10 @@ async function save(): Promise<void> { init(); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.accountMigration, icon: 'ph-airplane ph-bold ph-lg', -}); +})); </script> <style lang="scss"> diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue index 0e149fd461..3b3376a9a7 100644 --- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,11 +19,13 @@ import { ref, watch } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -const instanceMutes = ref($i!.mutedInstances.join('\n')); +const $i = signinRequired(); + +const instanceMutes = ref($i.mutedInstances.join('\n')); const changed = ref(false); async function save() { @@ -32,7 +34,7 @@ async function save() { .map(el => el.trim()) .filter(el => el); - await os.api('i/update', { + await misskeyApi('i/update', { mutedInstances: mutes, }); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index a996a03cce..588184826d 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ph-envelope ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.wordMute }}</template> - <XWordMute :muted="$i!.mutedWords" @save="saveMutedWords"/> + <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> </MkFolder> <MkFolder> <template #icon><i class="ph-x-square ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.hardWordMute }}</template> - <XWordMute :muted="$i!.hardMutedWords" @save="saveHardMutedWords"/> + <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> </MkFolder> <MkFolder> @@ -136,9 +136,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; import { infoImageUrl } from '@/instance.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import MkFolder from '@/components/MkFolder.vue'; +const $i = signinRequired(); + const renoteMutingPagination = { endpoint: 'renote-mute/list' as const, limit: 10, @@ -227,10 +229,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.muteAndBlock, icon: 'ph-prohibit ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/mute-block.word-mute.vue b/packages/frontend/src/pages/settings/mute-block.word-mute.vue index 96ee48cdba..faf16ca368 100644 --- a/packages/frontend/src/pages/settings/mute-block.word-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.word-mute.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -64,7 +64,7 @@ async function save() { os.alert({ type: 'error', title: i18n.ts.regexpError, - text: i18n.t('regexpErrorDescription', { tab: 'word mute', line: i + 1 }) + '\n' + err.toString(), + text: i18n.tsx.regexpErrorDescription({ tab: 'word mute', line: i + 1 }) + '\n' + err.toString(), }); // re-throw error so these invalid settings are not saved throw err; diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index f3c9ec8926..ae5f081e1c 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -118,10 +118,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.navbar, icon: 'ph-list ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue index 06686c3204..6dde006106 100644 --- a/packages/frontend/src/pages/settings/notifications.notification-config.vue +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,10 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <MkSelect v-model="type"> <option value="all">{{ i18n.ts.all }}</option> - <option value="following">{{ i18n.ts.following }}</option> - <option value="follower">{{ i18n.ts.followers }}</option> - <option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option> - <option value="list">{{ i18n.ts.userList }}</option> + <option value="following" v-if="hasSender">{{ i18n.ts.following }}</option> + <option value="follower" v-if="hasSender">{{ i18n.ts.followers }}</option> + <option value="mutualFollow" v-if="hasSender">{{ i18n.ts.mutualFollow }}</option> + <option value="followingOrFollower" v-if="hasSender">{{ i18n.ts.followingOrFollower }}</option> + <option value="list" v-if="hasSender">{{ i18n.ts.userList }}</option> <option value="never">{{ i18n.ts.none }}</option> </MkSelect> @@ -32,10 +33,13 @@ import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ value: any; userLists: Misskey.entities.UserList[]; -}>(); + hasSender: boolean; +}>(), { + hasSender: true, +}); const emit = defineEmits<{ (ev: 'update', result: any): void; diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 0bdfbdf741..36fe7df03e 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,19 +9,20 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> <div class="_gaps_s"> <MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type"> - <template #label>{{ i18n.t('_notification._types.' + type) }}</template> + <template #label>{{ i18n.ts._notification._types[type] }}</template> <template #suffix> {{ $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : $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 }} </template> - <XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" @update="(res) => updateReceiveConfig(type, res)"/> + <XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" :hasSender="!(notificationTypesWithoutSender.includes(type))" @update="(res) => updateReceiveConfig(type, res)"/> </MkFolder> </div> </FormSection> @@ -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> @@ -62,18 +64,22 @@ import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; import { notificationTypes } from '@/const.js'; -const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'achievementEarned']; +const $i = signinRequired(); + +const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted']; +const notificationTypesWithoutSender = ['achievementEarned']; const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false); -const userLists = await os.api('users/lists/list'); +const userLists = await misskeyApi('users/lists/list'); async function readAllUnreadNotes() { await os.apiWithDialog('i/read-all-unread-notes'); @@ -86,11 +92,11 @@ async function readAllNotifications() { async function updateReceiveConfig(type, value) { await os.apiWithDialog('i/update', { notificationRecieveConfig: { - ...$i!.notificationRecieveConfig, + ...$i.notificationRecieveConfig, [type]: value, }, }).then(i => { - $i!.notificationRecieveConfig = i.notificationRecieveConfig; + $i.notificationRecieveConfig = i.notificationRecieveConfig; }); } @@ -107,15 +113,26 @@ function onChangeSendReadMessage(v: boolean) { } function testNotification(): void { - os.api('notifications/test-notification'); + 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(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.notifications, icon: 'ph-bell ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index efda0c00b3..683e5f0e30 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -104,26 +104,21 @@ import FormInfo from '@/components/MkInfo.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; -import { signout, $i } from '@/account.js'; +import { signout, signinRequired } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import FormSection from '@/components/form/section.vue'; +const $i = signinRequired(); + const reportError = computed(defaultStore.makeGetterSetter('reportError')); const enableCondensedLineForAcct = computed(defaultStore.makeGetterSetter('enableCondensedLineForAcct')); const devMode = computed(defaultStore.makeGetterSetter('devMode')); const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies')); -function onChangeInjectFeaturedNote(v) { - os.api('i/update', { - injectFeaturedNote: v, - }).then((i) => { - $i!.injectFeaturedNote = i.injectFeaturedNote; - }); -} - async function deleteAccount() { { const { canceled } = await os.confirm({ @@ -165,11 +160,11 @@ async function updateRepliesAll(withReplies: boolean) { }); if (canceled) return; - os.api('following/update-all', { withReplies }); + misskeyApi('following/update-all', { withReplies }); } const exportData = () => { - os.api('i/export-data', {}).then(() => { + misskeyApi('i/export-data', {}).then(() => { os.alert({ type: 'info', text: i18n.ts.exportRequested, @@ -192,8 +187,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.other, icon: 'ph-dots-three ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index be8db548a4..f3dd862bd1 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -53,8 +53,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts._plugin.install, icon: 'ph-download ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 5b5c282f39..f1699f726e 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -125,8 +125,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.plugins, icon: 'ph-plug ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index c7538f3a1b..f180e0b72c 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -37,12 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { useStream } from '@/stream.js'; @@ -70,6 +71,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'animatedMfm', 'advancedMfm', 'loadRawImages', + 'warnMissingAltText', 'imageNewTab', 'dataSaver', 'disableShowingAnimatedImages', @@ -97,6 +99,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'showClipButtonInNoteFooter', 'reactionsDisplaySize', 'forceShowAds', + 'oneko', 'numberOfReplies', 'aiChanMode', 'devMode', @@ -146,7 +149,7 @@ const connection = $i && useStream().useChannel('main'); const profiles = ref<Record<string, Profile> | null>(null); -os.api('i/registry/get-all', { scope }) +misskeyApi('i/registry/get-all', { scope }) .then(res => { profiles.value = res || {}; }); @@ -205,6 +208,7 @@ async function saveNew(): Promise<void> { const { canceled, result: name } = await os.inputText({ title: ts._preferencesBackups.inputName, + default: '', }); if (canceled) return; @@ -380,6 +384,7 @@ async function rename(id: string): Promise<void> { const { canceled: cancel1, result: name } = await os.inputText({ title: ts._preferencesBackups.inputName, + default: '', }); if (cancel1 || profiles.value[id].name === name) return; @@ -446,10 +451,10 @@ onUnmounted(() => { connection?.off('registryUpdated'); }); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: ts.preferencesBackups, icon: 'ph-floppy-disk ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 62056ff8a6..86cf5ab241 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -77,12 +77,14 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +const $i = signinRequired(); + const isLocked = ref($i.isLocked); const autoAcceptFollowed = ref($i.autoAcceptFollowed); const noCrawle = ref($i.noCrawle); @@ -90,8 +92,8 @@ const noindex = ref($i.noindex); const isExplorable = ref($i.isExplorable); const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); -const followingVisibility = ref($i?.followingVisibility); -const followersVisibility = ref($i?.followersVisibility); +const followingVisibility = ref($i.followingVisibility); +const followersVisibility = ref($i.followersVisibility); const defaultNoteVisibility = computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); @@ -99,7 +101,7 @@ const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberN const keepCw = computed(defaultStore.makeGetterSetter('keepCw')); function save() { - os.api('i/update', { + misskeyApi('i/update', { isLocked: !!isLocked.value, autoAcceptFollowed: !!autoAcceptFollowed.value, noCrawle: !!noCrawle.value, @@ -116,8 +118,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.privacy, icon: 'ph-lock ph-bold ph-lg-open', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 4bae635d05..408cf4ed67 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -127,14 +127,17 @@ import FormSlot from '@/components/form/slot.vue'; import { selectFile } from '@/scripts/select-file.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { langmap } from '@/scripts/langmap.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; +import { globalEvents } from '@/events.js'; import MkInfo from '@/components/MkInfo.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +const $i = signinRequired(); + const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); @@ -152,11 +155,11 @@ const profile = reactive({ description: $i.description, location: $i.location, birthday: $i.birthday, - listenbrainz: $i?.listenbrainz, + listenbrainz: $i.listenbrainz, lang: $i.lang, - isBot: $i.isBot, - isCat: $i.isCat, - speakAsCat: $i.speakAsCat, + isBot: $i.isBot ?? false, + isCat: $i.isCat ?? false, + speakAsCat: $i.speakAsCat ?? false, }); watch(() => profile, () => { @@ -165,7 +168,7 @@ watch(() => profile, () => { deep: true, }); -const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []); +const fields = ref($i.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []); const fieldEditMode = ref(false); function addField() { @@ -188,6 +191,7 @@ function saveFields() { os.apiWithDialog('i/update', { fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })), }); + globalEvents.emit('requestClearPageCache'); } function save() { @@ -215,6 +219,7 @@ function save() { isCat: !!profile.isCat, speakAsCat: !!profile.speakAsCat, }); + globalEvents.emit('requestClearPageCache'); claimAchievement('profileFilled'); if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { claimAchievement('setNameToSyuilo'); @@ -230,7 +235,7 @@ function changeAvatar(ev) { const { canceled } = await os.confirm({ type: 'question', - text: i18n.t('cropImageAsk'), + text: i18n.ts.cropImageAsk, okText: i18n.ts.cropYes, cancelText: i18n.ts.cropNo, }); @@ -246,68 +251,153 @@ function changeAvatar(ev) { }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; + globalEvents.emit('requestClearPageCache'); claimAchievement('profileFilled'); }); } function changeBanner(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { - let originalOrCropped = file; + if ($i.bannerId) { + os.popupMenu([{ + text: i18n.ts._profile.updateBanner, + action: async () => { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.t('cropImageAsk'), - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 2, + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 2, + }); + } + + const i = await os.apiWithDialog('i/update', { + bannerId: originalOrCropped.id, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + globalEvents.emit('requestClearPageCache'); + }); + }, + }, { + text: i18n.ts._profile.removeBanner, + action: async () => { + const i = await os.apiWithDialog('i/update', { + bannerId: null, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + globalEvents.emit('requestClearPageCache'); + }, + }], ev.currentTarget ?? ev.target); + } else { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, }); - } - const i = await os.apiWithDialog('i/update', { - bannerId: originalOrCropped.id, + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 2, + }); + } + + const i = await os.apiWithDialog('i/update', { + bannerId: originalOrCropped.id, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + globalEvents.emit('requestClearPageCache'); }); - $i.bannerId = i.bannerId; - $i.bannerUrl = i.bannerUrl; - }); + } } function changeBackground(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.background).then(async (file) => { - let originalOrCropped = file; + if ($i.backgroundId) { + os.popupMenu([{ + text: i18n.ts._profile.updateBackground, + action: async () => { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.background).then(async (file) => { + let originalOrCropped = file; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.t('cropImageAsk'), - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + backgroundId: originalOrCropped.id, + }); + $i.backgroundId = i.backgroundId; + $i.backgroundUrl = i.backgroundUrl; + globalEvents.emit('requestClearPageCache'); + }); + }, + }, { + text: i18n.ts._profile.removeBackground, + action: async () => { + const i = await os.apiWithDialog('i/update', { + backgroundId: null, + }); + $i.backgroundId = i.backgroundId; + $i.backgroundUrl = i.backgroundUrl; + globalEvents.emit('requestClearPageCache'); + }, + }], ev.currentTarget ?? ev.target); + } else { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.background).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, }); - } - const i = await os.apiWithDialog('i/update', { - backgroundId: originalOrCropped.id, + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + backgroundId: originalOrCropped.id, + }); + $i.backgroundId = i.backgroundId; + $i.backgroundUrl = i.backgroundUrl; + globalEvents.emit('requestClearPageCache'); }); - $i.backgroundId = i.backgroundId; - $i.backgroundUrl = i.backgroundUrl; - }); + } } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.profile, icon: 'ph-user ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue index 716b168c92..273cf013f0 100644 --- a/packages/frontend/src/pages/settings/roles.vue +++ b/packages/frontend/src/pages/settings/roles.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,24 +27,20 @@ import { computed } from 'vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { signinRequired } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; -function save() { - os.apiWithDialog('i/update', { - - }); -} +const $i = signinRequired(); const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.roles, icon: 'ph-seal-check ph-bold ph-lg', -}); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index 9ae479e6e4..43e5104d71 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -47,6 +47,7 @@ import FormSlot from '@/components/form/slot.vue'; import MkButton from '@/components/MkButton.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -92,7 +93,7 @@ async function regenerateToken() { const auth = await os.authenticateDialog(); if (auth.canceled) return; - os.api('i/regenerate-token', { + misskeyApi('i/regenerate-token', { password: auth.result.password, token: auth.result.token, }); @@ -102,10 +103,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.security, icon: 'ph-lock ph-bold ph-lg', -}); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index a43ffb1f0b..307c5eaae4 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -32,7 +32,8 @@ import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { playFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js'; import { selectFile } from '@/scripts/select-file.js'; const props = defineProps<{ @@ -53,7 +54,7 @@ const fileName = ref<string>(''); const volume = ref(props.volume); if (type.value === '_driveFile_' && fileId.value) { - const apiRes = await os.api('drive/files/show', { + const apiRes = await misskeyApi('drive/files/show', { fileId: fileId.value, }); fileName.value = apiRes.name; @@ -118,7 +119,7 @@ function listen() { return; } - playFile(type.value === '_driveFile_' ? { + playMisskeySfxFile(type.value === '_driveFile_' ? { type: '_driveFile_', fileId: fileId.value as string, fileUrl: fileUrl.value as string, diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index bec41a6cec..bf398ac303 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.sounds }}</template> <div class="_gaps_s"> <MkFolder v-for="type in operationTypes" :key="type"> - <template #label>{{ i18n.t('_sfx.' + type) }}</template> + <template #label>{{ i18n.ts._sfx[type] }}</template> <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template> <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> @@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { Ref, computed, ref } from 'vue'; +import XSound from './sounds.sound.vue'; import type { SoundType, OperationType } from '@/scripts/sound.js'; import type { SoundStore } from '@/store.js'; -import XSound from './sounds.sound.vue'; import MkRange from '@/components/MkRange.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; @@ -94,8 +94,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.sounds, icon: 'ph-music-notes ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index de5f1a3db9..92e389a288 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue index c45e386ac5..fa924d13f0 100644 --- a/packages/frontend/src/pages/settings/statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -21,7 +21,7 @@ import { v4 as uuid } from 'uuid'; import XStatusbar from './statusbar.statusbar.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -31,7 +31,7 @@ const statusbars = defaultStore.reactiveState.statusbars; const userLists = ref<Misskey.entities.UserList[] | null>(null); onMounted(() => { - os.api('users/lists/list').then(res => { + misskeyApi('users/lists/list').then(res => { userLists.value = res; }); }); @@ -50,8 +50,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.statusbar, icon: 'ph-list ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue index d377590b9d..01ae5286b7 100644 --- a/packages/frontend/src/pages/settings/theme.install.vue +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -33,7 +33,7 @@ async function install(code: string): Promise<void> { await installTheme(code); os.alert({ type: 'success', - text: i18n.t('_theme.installed', { name: theme.name }), + text: i18n.tsx._theme.installed({ name: theme.name }), }); } catch (err) { switch (err.message.toLowerCase()) { @@ -59,8 +59,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts._theme.install, icon: 'ph-download ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index f7856d122f..43d76951c0 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -76,8 +76,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts._theme.manage, icon: 'ph-wrench ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index cb9c714441..9b493f5ffe 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -88,6 +88,18 @@ import { uniqueBy } from '@/scripts/array.js'; import { fetchThemes, getThemes } from '@/theme-store.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { miLocalStorage } from '@/local-storage.js'; +import { unisonReload } from '@/scripts/unison-reload.js'; +import * as os from '@/os.js'; + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); @@ -124,6 +136,7 @@ const lightThemeId = computed({ } }, }); + const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); const wallpaper = ref(miLocalStorage.getItem('wallpaper')); @@ -141,7 +154,7 @@ watch(wallpaper, () => { } else { miLocalStorage.setItem('wallpaper', wallpaper.value); } - location.reload(); + reloadAsk(); }); onActivated(() => { @@ -164,10 +177,10 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.theme, icon: 'ph-palette ph-bold ph-lg', -}); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index f6e2f63317..99326c8671 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -48,9 +48,10 @@ import FormSection from '@/components/form/section.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -58,7 +59,7 @@ const props = defineProps<{ webhookId: string; }>(); -const webhook = await os.api('i/webhooks/show', { +const webhook = await misskeyApi('i/webhooks/show', { webhookId: props.webhookId, }); @@ -98,7 +99,7 @@ async function save(): Promise<void> { async function del(): Promise<void> { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: webhook.name }), + text: i18n.tsx.deleteAreYouSure({ x: webhook.name }), }); if (canceled) return; @@ -113,8 +114,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: 'Edit webhook', icon: 'ph-webhooks-logo ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 032796caf0..299386338a 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -82,8 +82,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: 'Create new webhook', icon: 'ph-webhooks-logo ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue index c391458274..3717abb13e 100644 --- a/packages/frontend/src/pages/settings/webhook.vue +++ b/packages/frontend/src/pages/settings/webhook.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -50,8 +50,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: 'Webhook', icon: 'ph-webhooks-logo ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index a978be0ae5..1eeeb587eb 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -37,6 +37,7 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { postMessageToParentWindow } from '@/scripts/post-message.js'; import { i18n } from '@/i18n.js'; @@ -55,7 +56,7 @@ const renote = ref<Misskey.entities.Note | undefined>(); const visibility = ref(Misskey.noteVisibilities.includes(visibilityQuery) ? visibilityQuery : undefined); const localOnly = ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : undefined); const files = ref([] as Misskey.entities.DriveFile[]); -const visibleUsers = ref([] as Misskey.entities.User[]); +const visibleUsers = ref([] as Misskey.entities.UserDetailed[]); async function init() { let noteText = ''; @@ -76,7 +77,7 @@ async function init() { ] // TypeScriptの指示通りに変換する .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) - .map(q => os.api('users/show', q) + .map(q => misskeyApi('users/show', q) .then(user => { visibleUsers.value.push(user); }, () => { @@ -91,11 +92,11 @@ async function init() { const replyId = urlParams.get('replyId'); const replyUri = urlParams.get('replyUri'); if (replyId) { - reply.value = await os.api('notes/show', { + reply.value = await misskeyApi('notes/show', { noteId: replyId, }); } else if (replyUri) { - const obj = await os.api('ap/show', { + const obj = await misskeyApi('ap/show', { uri: replyUri, }); if (obj.type === 'Note') { @@ -108,11 +109,11 @@ async function init() { const renoteId = urlParams.get('renoteId'); const renoteUri = urlParams.get('renoteUri'); if (renoteId) { - renote.value = await os.api('notes/show', { + renote.value = await misskeyApi('notes/show', { noteId: renoteId, }); } else if (renoteUri) { - const obj = await os.api('ap/show', { + const obj = await misskeyApi('ap/show', { uri: renoteUri, }); if (obj.type === 'Note') { @@ -126,7 +127,7 @@ async function init() { if (fileIds) { await Promise.all( fileIds.split(',') - .map(fileId => os.api('drive/files/show', { fileId }) + .map(fileId => misskeyApi('drive/files/show', { fileId }) .then(file => { files.value.push(file); }, () => { @@ -171,8 +172,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.share, icon: 'ph-share-network ph-bold ph-lg', -}); +})); </script> diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue index 4009652bcf..b08a304cfd 100644 --- a/packages/frontend/src/pages/signup-complete.vue +++ b/packages/frontend/src/pages/signup-complete.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ph-check ph-bold ph-lg"></i> </div> <div class="_gaps_m" style="padding: 32px;"> - <div>{{ i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }) }}</div> + <div>{{ i18n.tsx.clickToFinishEmailVerification({ ok: i18n.ts.gotIt }) }}</div> <div> <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;"> {{ submitting ? i18n.ts.processing : i18n.ts.gotIt }}<MkEllipsis v-if="submitting"/> @@ -31,6 +31,7 @@ import MkAnimBg from '@/components/MkAnimBg.vue'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const submitting = ref(false); @@ -42,7 +43,7 @@ function submit() { if (submitting.value) return; submitting.value = true; - os.api('signup-pending', { + misskeyApi('signup-pending', { code: props.code, }).then(res => { if (res.pendingApproval) { diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index 167816638c..d9c94569a7 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="$i" #footer> <div :class="$style.footer"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="16"> - <MkButton rounded primary :class="$style.button" @click="post()"><i class="ph-pencil ph-bold ph-lg"></i>{{ i18n.ts.postToHashtag }}</MkButton> + <MkButton rounded primary :class="$style.button" @click="post()"><i class="ph-pencil-simple ph-bold ph-lg"></i>{{ i18n.ts.postToHashtag }}</MkButton> </MkSpacer> </div> </template> @@ -55,21 +55,22 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: props.tag, icon: 'ph-hash ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> .footer { -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); + background: var(--acrylicBg); border-top: solid 0.5px var(--divider); display: flex; } .button { - margin: 0 auto var(--margin) auto; + margin: 0 auto; } </style> diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 4b4196d0a9..d020320b44 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -186,7 +186,7 @@ function applyThemeCode() { async function saveAs() { const { canceled, result: name } = await os.inputText({ title: i18n.ts.name, - allowEmpty: false, + minLength: 1, }); if (canceled) return; @@ -204,7 +204,7 @@ async function saveAs() { changed.value = false; os.alert({ type: 'success', - text: i18n.t('_theme.installed', { name: theme.value.name }), + text: i18n.tsx._theme.installed({ name: theme.value.name }), }); } @@ -219,10 +219,10 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata({ +definePageMetadata(() => ({ title: i18n.ts.themeEditor, icon: 'ph-palette ph-bold ph-lg', -}); +})); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index f5cefeddb4..a9f7a163f6 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,28 +7,29 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> <MkSpacer :contentMax="800"> - <div ref="rootEl" v-hotkey.global="keymap"> - <MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()"> - {{ i18n.ts._timelineDescription[src] }} - </MkInfo> - <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> - - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div :class="$style.tl"> - <MkTimeline - ref="tlComponent" - :key="src + withRenotes + withReplies + onlyFiles" - :src="src.split(':')[0]" - :list="src.split(':')[1]" - :withRenotes="withRenotes" - :withReplies="withReplies" - :onlyFiles="onlyFiles" - :withBots="withBots" - :sound="true" - @queue="queueUpdated" - /> + <MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin"> + <div :key="src" ref="rootEl" v-hotkey.global="keymap"> + <MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()"> + {{ i18n.ts._timelineDescription[src] }} + </MkInfo> + <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> + <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div :class="$style.tl"> + <MkTimeline + ref="tlComponent" + :key="src + withRenotes + withReplies + onlyFiles" + :src="src.split(':')[0]" + :list="src.split(':')[1]" + :withRenotes="withRenotes" + :withReplies="withReplies" + :onlyFiles="onlyFiles" + :withBots="withBots" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> - </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> @@ -39,8 +40,10 @@ import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { scroll } from '@/scripts/scroll.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; @@ -48,6 +51,7 @@ import { $i } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache, userListsCache } from '@/cache.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -64,17 +68,68 @@ const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); const rootEl = shallowRef<HTMLElement>(); const queue = ref(0); -const srcWhenNotSignin = ref(isLocalTimelineAvailable ? 'local' : 'global'); -const src = computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x) }); -const withRenotes = ref(true); -const withReplies = ref($i ? defaultStore.state.tlWithReplies : false); -const withBots = ref($i ? defaultStore.state.tlWithBots : true); -const onlyFiles = ref(false); +const srcWhenNotSignin = ref<'local' | 'global'>(isLocalTimelineAvailable ? 'local' : 'global'); +const src = computed<'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`>({ + get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), + set: (x) => saveSrc(x), +}); +const withRenotes = computed<boolean>({ + get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, + set: (x) => saveTlFilter('withRenotes', x), +}); + +// computed内での無限ループを防ぐためのフラグ +const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>('withReplies'); + +const withReplies = computed<boolean>({ + get: () => { + if (!$i) return false; + if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { + return false; + } else { + return defaultStore.reactiveState.tl.value.filter.withReplies; + } + }, + set: (x) => saveTlFilter('withReplies', x), +}); +const withBots = computed<boolean>({ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + get: () => (defaultStore.reactiveState.tl.value.filter?.withBots ?? saveTlFilter('withBots', true)), + set: (x) => saveTlFilter('withBots', x), +}); +const onlyFiles = computed<boolean>({ + get: () => { + if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { + return false; + } else { + return defaultStore.reactiveState.tl.value.filter.onlyFiles; + } + }, + set: (x) => saveTlFilter('onlyFiles', x), +}); + +watch([withReplies, onlyFiles], ([withRepliesTo, onlyFilesTo]) => { + if (withRepliesTo) { + localSocialTLFilterSwitchStore.value = 'withReplies'; + } else if (onlyFilesTo) { + localSocialTLFilterSwitchStore.value = 'onlyFiles'; + } else { + localSocialTLFilterSwitchStore.value = false; + } +}); -watch(src, () => queue.value = 0); +const withSensitive = computed<boolean>({ + get: () => defaultStore.reactiveState.tl.value.filter.withSensitive, + set: (x) => saveTlFilter('withSensitive', x), +}); + +watch(src, () => { + queue.value = 0; +}); -watch(withReplies, (x) => { - if ($i) defaultStore.set('tlWithReplies', x); +watch(withSensitive, () => { + // これだけはクライアント側で完結する処理なので手動でリロード + tlComponent.value?.reloadTimeline(); }); function queueUpdated(q: number): void { @@ -125,7 +180,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { } async function chooseChannel(ev: MouseEvent): Promise<void> { - const channels = await os.api('channels/my-favorites', { + const channels = await misskeyApi('channels/my-favorites', { limit: 100, }); const items: MenuItem[] = [ @@ -152,16 +207,24 @@ async function chooseChannel(ev: MouseEvent): Promise<void> { } function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`): void { - let userList = null; + const out = deepMerge({ src: newSrc }, defaultStore.state.tl); + if (newSrc.startsWith('userList:')) { const id = newSrc.substring('userList:'.length); - userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id); + out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null; + } + + defaultStore.set('tl', out); + if (['local', 'global'].includes(newSrc)) { + srcWhenNotSignin.value = newSrc as 'local' | 'global'; + } +} + +function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) { + if (key !== 'withReplies' || $i) { + const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl); + defaultStore.set('tl', out); } - defaultStore.set('tl', { - src: newSrc, - userList, - }); - srcWhenNotSignin.value = newSrc; } async function timetravel(): Promise<void> { @@ -201,6 +264,10 @@ const headerActions = computed(() => { disabled: onlyFiles, } : undefined, { type: 'switch', + text: i18n.ts.withSensitive, + ref: withSensitive, + }, { + type: 'switch', text: i18n.ts.fileAttachedOnly, ref: onlyFiles, disabled: src.value === 'local' || src.value === 'social' ? withReplies : false, @@ -213,8 +280,7 @@ const headerActions = computed(() => { icon: 'ph-arrows-counter-clockwise ph-bold ph-lg', text: i18n.ts.reload, handler: (ev: Event) => { - console.log('called'); - tlComponent.value.reloadTimeline(); + tlComponent.value?.reloadTimeline(); }, }); } @@ -283,10 +349,10 @@ const headerTabsWhenNotLogin = computed(() => [ }] : []), ] as Tab[]); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: i18n.ts.timeline, icon: src.value === 'local' ? 'ph-planet ph-bold ph-lg' : src.value === 'social' ? 'ph-rocket-launch ph-bold ph-lg' : src.value === 'global' ? 'ph-globe-hemisphere-west ph-bold ph-lg' : src.value === 'bubble' ? 'ph-drop ph-bold ph-lg' : 'ph-house ph-bold ph-lg', -}))); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 3ec23df7b8..dd0b7fb675 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -28,10 +28,10 @@ import { computed, watch, ref, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll.js'; -import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; +import { useRouter } from '@/router/supplier.js'; const router = useRouter(); @@ -45,7 +45,7 @@ const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); const rootEl = shallowRef<HTMLElement>(); watch(() => props.listId, async () => { - list.value = await os.api('users/lists/show', { + list.value = await misskeyApi('users/lists/show', { listId: props.listId, }); }, { immediate: true }); @@ -70,10 +70,10 @@ const headerActions = computed(() => list.value ? [{ const headerTabs = computed(() => []); -definePageMetadata(computed(() => list.value ? { - title: list.value.name, +definePageMetadata(() => ({ + title: list.value ? list.value.name : i18n.ts.lists, icon: 'ph-list ph-bold ph-lg', -} : null)); +})); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue index 7e6757bba5..b6ebfb8abc 100644 --- a/packages/frontend/src/pages/user-tag.vue +++ b/packages/frontend/src/pages/user-tag.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -34,9 +34,9 @@ const tagUsers = computed(() => ({ }, })); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: props.tag, icon: 'ph-user-circle ph-bold ph-lg', -}))); +})); </script> diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue index 4e14443074..403e74904c 100644 --- a/packages/frontend/src/pages/user/achievements.vue +++ b/packages/frontend/src/pages/user/achievements.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index bd1159cb32..aa2c791c76 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,7 +18,7 @@ import { onMounted, shallowRef, ref } from 'vue'; import { Chart, ChartDataset } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -61,7 +61,7 @@ async function renderChart() { })); }; - const raw = await os.api('charts/user/following', { userId: props.user.id, limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/user/following', { userId: props.user.id, limit: chartLimit, span: 'day' }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; diff --git a/packages/frontend/src/pages/user/activity.heatmap.vue b/packages/frontend/src/pages/user/activity.heatmap.vue deleted file mode 100644 index ff46db9653..0000000000 --- a/packages/frontend/src/pages/user/activity.heatmap.vue +++ /dev/null @@ -1,219 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div ref="rootEl"> - <MkLoading v-if="fetching"/> - <div v-else :class="$style.root" class="_panel"> - <canvas ref="chartEl"></canvas> - </div> -</div> -</template> - -<script lang="ts" setup> -import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; -import { Chart } from 'chart.js'; -import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; - -initChart(); - -const props = defineProps<{ - src: string; - user: Misskey.entities.User; -}>(); - -const rootEl = shallowRef<HTMLDivElement>(null); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const now = new Date(); -let chartInstance: Chart = null; -const fetching = ref(true); - -const { handler: externalTooltipHandler } = useChartTooltip({ - position: 'middle', -}); - -async function renderChart() { - if (chartInstance) { - chartInstance.destroy(); - } - - const wide = rootEl.value.offsetWidth > 700; - const narrow = rootEl.value.offsetWidth < 400; - - const weeks = wide ? 50 : narrow ? 10 : 25; - const chartLimit = 7 * weeks; - - const getDate = (ago: number) => { - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - - return new Date(y, m, d - ago); - }; - - const format = (arr) => { - return arr.map((v, i) => { - const dt = getDate(i); - const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; - return { - x: iso, - y: dt.getDay(), - d: iso, - v, - }; - }); - }; - - let values; - - if (props.src === 'notes') { - const raw = await os.api('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); - values = raw.inc; - } - - fetching.value = false; - - await nextTick(); - - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; - - // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする - const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; - - const min = Math.max(0, Math.min(...values) - 1); - - const marginEachCell = 4; - - chartInstance = new Chart(chartEl.value, { - type: 'matrix', - data: { - datasets: [{ - label: '', - data: format(values), - pointRadius: 0, - borderWidth: 0, - borderJoinStyle: 'round', - borderRadius: 3, - backgroundColor(c) { - const value = c.dataset.data[c.dataIndex].v; - let a = (value - min) / max; - if (value !== 0) { // 0でない限りは完全に不可視にはしない - a = Math.max(a, 0.05); - } - return alpha(color, a); - }, - fill: true, - width(c) { - const a = c.chart.chartArea ?? {}; - return (a.right - a.left) / weeks - marginEachCell; - }, - height(c) { - const a = c.chart.chartArea ?? {}; - return (a.bottom - a.top) / 7 - marginEachCell; - }, - /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> - }] satisfies ChartData[], - */ - }], - }, - options: { - aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2, - layout: { - padding: { - left: 8, - right: 0, - top: 0, - bottom: 0, - }, - }, - scales: { - x: { - type: 'time', - offset: true, - position: 'bottom', - time: { - unit: 'week', - round: 'week', - isoWeekday: 0, - displayFormats: { - day: 'M/d', - month: 'Y/M', - week: 'M/d', - }, - }, - grid: { - display: false, - }, - ticks: { - display: true, - maxRotation: 0, - autoSkipPadding: 8, - }, - }, - y: { - offset: true, - reverse: true, - position: 'right', - grid: { - display: false, - }, - ticks: { - maxRotation: 0, - autoSkip: true, - padding: 1, - font: { - size: 9, - }, - callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], - }, - }, - }, - plugins: { - legend: { - display: false, - }, - tooltip: { - enabled: false, - callbacks: { - title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; - return v.d; - }, - label(context) { - const v = context.dataset.data[context.dataIndex]; - return [v.v]; - }, - }, - //mode: 'index', - animation: { - duration: 0, - }, - external: externalTooltipHandler, - }, - }, - }, - }); -} - -watch(() => props.src, () => { - fetching.value = true; - renderChart(); -}); - -onMounted(async () => { - renderChart(); -}); -</script> - -<style lang="scss" module> -.root { - padding: 20px; -} -</style> diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index dd035641d8..64514716d6 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,7 +18,7 @@ import { onMounted, shallowRef, ref } from 'vue'; import { Chart, ChartDataset } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -61,7 +61,7 @@ async function renderChart() { })); }; - const raw = await os.api('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index 2dd9a1570f..ce24807f93 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,7 +18,7 @@ import { onMounted, shallowRef, ref } from 'vue'; import { Chart, ChartDataset } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -61,7 +61,7 @@ async function renderChart() { })); }; - const raw = await os.api('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' }); + const raw = await misskeyApi('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' }); const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; diff --git a/packages/frontend/src/pages/user/activity.vue b/packages/frontend/src/pages/user/activity.vue index 42035cc619..271631e8d1 100644 --- a/packages/frontend/src/pages/user/activity.vue +++ b/packages/frontend/src/pages/user/activity.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <MkFoldableSection class="item"> <template #header><i class="ph-pulse ph-bold ph-lg"></i> Heatmap</template> - <XHeatmap :user="user" :src="'notes'"/> + <MkHeatmap :user="user" :src="'notes'"/> </MkFoldableSection> <MkFoldableSection class="item"> - <template #header><i class="ph-pencil ph-bold ph-lg"></i> Notes</template> + <template #header><i class="ph-pencil-simple ph-bold ph-lg"></i> Notes</template> <XNotes :user="user"/> </MkFoldableSection> <MkFoldableSection class="item"> @@ -28,11 +28,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import XHeatmap from './activity.heatmap.vue'; import XPv from './activity.pv.vue'; import XNotes from './activity.notes.vue'; import XFollowing from './activity.following.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import MkHeatmap from '@/components/MkHeatmap.vue'; const props = defineProps<{ user: Misskey.entities.User; diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue index eaae472516..ac01cff8cd 100644 --- a/packages/frontend/src/pages/user/clips.vue +++ b/packages/frontend/src/pages/user/clips.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/flashs.vue b/packages/frontend/src/pages/user/flashs.vue index 5e93a0b04c..b3313476e1 100644 --- a/packages/frontend/src/pages/user/flashs.vue +++ b/packages/frontend/src/pages/user/flashs.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue index 19b7290353..e60dccec17 100644 --- a/packages/frontend/src/pages/user/follow-list.vue +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue index 36f1b4543e..e8addf88b7 100644 --- a/packages/frontend/src/pages/user/followers.vue +++ b/packages/frontend/src/pages/user/followers.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XFollowList from './follow-list.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; @@ -37,7 +37,7 @@ const error = ref<any>(null); function fetchUser(): void { if (props.acct == null) return; user.value = null; - os.api('users/show', Misskey.acct.parse(props.acct)).then(u => { + misskeyApi('users/show', Misskey.acct.parse(props.acct)).then(u => { user.value = u; }).catch(err => { error.value = err; @@ -52,11 +52,14 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => user.value ? { +definePageMetadata(() => ({ + title: i18n.ts.user, icon: 'ph-user ph-bold ph-lg', - title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, - subtitle: i18n.ts.followers, - userName: user.value, - avatar: user.value, -} : null)); + ...user.value ? { + title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, + subtitle: i18n.ts.followers, + userName: user.value, + avatar: user.value, + } : {}, +})); </script> diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue index 43876b77c0..8e4da40383 100644 --- a/packages/frontend/src/pages/user/following.vue +++ b/packages/frontend/src/pages/user/following.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XFollowList from './follow-list.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; @@ -37,7 +37,7 @@ const error = ref<any>(null); function fetchUser(): void { if (props.acct == null) return; user.value = null; - os.api('users/show', Misskey.acct.parse(props.acct)).then(u => { + misskeyApi('users/show', Misskey.acct.parse(props.acct)).then(u => { user.value = u; }).catch(err => { error.value = err; @@ -52,11 +52,14 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => user.value ? { +definePageMetadata(() => ({ + title: i18n.ts.user, icon: 'ph-user ph-bold ph-lg', - title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, - subtitle: i18n.ts.following, - userName: user.value, - avatar: user.value, -} : null)); + ...user.value ? { + title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, + subtitle: i18n.ts.following, + userName: user.value, + avatar: user.value, + } : {}, +})); </script> diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue index 0d806100d9..9ba81322ba 100644 --- a/packages/frontend/src/pages/user/gallery.vue +++ b/packages/frontend/src/pages/user/gallery.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/home.stories.impl.ts b/packages/frontend/src/pages/user/home.stories.impl.ts index a2ef5d50d1..c623ef9ee4 100644 --- a/packages/frontend/src/pages/user/home.stories.impl.ts +++ b/packages/frontend/src/pages/user/home.stories.impl.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; -import { rest } from 'msw'; +import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../../.storybook/fakes.js'; import { commonHandlers } from '../../../.storybook/mocks.js'; import home_ from './home.vue'; @@ -39,12 +39,13 @@ export const Default = { msw: { handlers: [ ...commonHandlers, - rest.post('/api/users/notes', (req, res, ctx) => { - return res(ctx.json([])); + http.post('/api/users/notes', () => { + return HttpResponse.json([]); }), - rest.get('/api/charts/user/notes', (req, res, ctx) => { - const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300); - return res(ctx.json({ + http.get('/api/charts/user/notes', ({ request }) => { + const url = new URL(request.url); + const length = Math.max(Math.min(parseInt(url.searchParams.get('limit') ?? '30', 10), 1), 300); + return HttpResponse.json({ total: Array.from({ length }, () => 0), inc: Array.from({ length }, () => 0), dec: Array.from({ length }, () => 0), @@ -54,11 +55,12 @@ export const Default = { renote: Array.from({ length }, () => 0), withFile: Array.from({ length }, () => 0), }, - })); + }); }), - rest.get('/api/charts/user/pv', (req, res, ctx) => { - const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300); - return res(ctx.json({ + http.get('/api/charts/user/pv', ({ request }) => { + const url = new URL(request.url); + const length = Math.max(Math.min(parseInt(url.searchParams.get('limit') ?? '30', 10), 1), 300); + return HttpResponse.json({ upv: { user: Array.from({ length }, () => 0), visitor: Array.from({ length }, () => 0), @@ -67,7 +69,7 @@ export const Default = { user: Array.from({ length }, () => 0), visitor: Array.from({ length }, () => 0), }, - })); + }); }), ], }, diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 44a8ca250b..96ae4824f0 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -1,10 +1,10 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkSpacer :contentMax="narrow ? 800 : 1100" :style="background"> +<MkSpacer :contentMax="narrow ? 800 : 1100" :style="background" style="transform: none !important;"> <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> <div class="main _gaps"> <MkInfo v-if="user.isSuspended" :warn="true">{{ i18n.ts.userSuspended }}</MkInfo> @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ph-lock ph-bold ph-lg"></i></span> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ph-robot ph-bold ph-lg"></i></span> <button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> - <i class="ph-pencil-line ph-bold ph-lg"/> {{ i18n.ts.addMemo }} + <i class="ph-pencil-simple-line ph-bold ph-lg"/> {{ i18n.ts.addMemo }} </button> </div> </div> @@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only </dl> <dl v-if="user.birthday" class="field"> <dt class="name"><i class="ph-cake ph-bold ph-lg ti-fw"></i> {{ i18n.ts.birthday }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.t('yearsOld', { age }) }})</dd> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd> </dl> <dl class="field"> <dt class="name"><i class="ph-calendar ph-bold ph-lg ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> @@ -185,14 +185,14 @@ import { getUserMenu } from '@/scripts/get-user-menu.js'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { $i, iAmModerator } from '@/account.js'; import { dateString } from '@/filters/date.js'; import { confetti } from '@/scripts/confetti.js'; -import { api } from '@/os.js'; import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { useRouter } from '@/router/supplier.js'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -262,7 +262,7 @@ const background = computed(() => { }); watch(moderationNote, async () => { - await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote.value }); + await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value }); }); const pagination = { @@ -279,7 +279,7 @@ const AllPagination = { params: computed(() => ({ userId: props.user.id, withRenotes: noteview.value === 'all', - withReplies: noteview.value === 'all' || noteview.value === 'files', + withReplies: noteview.value === 'all', withChannelNotes: noteview.value === 'all', withFiles: noteview.value === 'files', })), @@ -333,7 +333,7 @@ function adjustMemoTextarea() { } async function updateMemo() { - await api('users/update-memo', { + await misskeyApi('users/update-memo', { memo: memoDraft.value, userId: props.user.id, }); diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index f555486a6d..857cf996ff 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue index 30817db77c..be58cec24a 100644 --- a/packages/frontend/src/pages/user/index.files.vue +++ b/packages/frontend/src/pages/user/index.files.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -37,7 +37,7 @@ import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { notePage } from '@/filters/note.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store.js'; @@ -61,7 +61,7 @@ function thumbnail(image: Misskey.entities.DriveFile): string { } onMounted(() => { - os.api('users/notes', { + misskeyApi('users/notes', { userId: props.user.id, withFiles: true, limit: 15, diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index e5a0f49e3d..8dbf90f344 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 44b4f84ca3..7f20e941d3 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,19 +8,21 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader v-model:tab="tab" :displayBackButton="true" :actions="headerActions" :tabs="headerTabs"/></template> <div> <div v-if="user"> - <XHome v-if="tab === 'home'" :user="user"/> - <MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0"> - <XTimeline :user="user"/> - </MkSpacer> - <XActivity v-else-if="tab === 'activity'" :user="user"/> - <XAchievements v-else-if="tab === 'achievements'" :user="user"/> - <XReactions v-else-if="tab === 'reactions'" :user="user"/> - <XClips v-else-if="tab === 'clips'" :user="user"/> - <XLists v-else-if="tab === 'lists'" :user="user"/> - <XPages v-else-if="tab === 'pages'" :user="user"/> - <XFlashs v-else-if="tab === 'flashs'" :user="user"/> - <XGallery v-else-if="tab === 'gallery'" :user="user"/> - <XRaw v-else-if="tab === 'raw'" :user="user"/> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'" key="home" :user="user"/> + <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0"> + <XTimeline :user="user"/> + </MkSpacer> + <XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/> + <XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/> + <XClips v-else-if="tab === 'clips'" key="clips" :user="user"/> + <XLists v-else-if="tab === 'lists'" key="lists" :user="user"/> + <XPages v-else-if="tab === 'pages'" key="pages" :user="user"/> + <XFlashs v-else-if="tab === 'flashs'" key="flashs" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" key="gallery" :user="user"/> + <XRaw v-else-if="tab === 'raw'" key="raw" :user="user"/> + </MkHorizontalSwipe> </div> <MkError v-else-if="error" @retry="fetchUser()"/> <MkLoading v-else/> @@ -32,10 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent, computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { acct as getAcct } from '@/filters/user.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); @@ -57,13 +60,14 @@ const props = withDefaults(defineProps<{ }); const tab = ref(props.page); + const user = ref<null | Misskey.entities.UserDetailed>(null); const error = ref<any>(null); function fetchUser(): void { if (props.acct == null) return; user.value = null; - os.api('users/show', Misskey.acct.parse(props.acct)).then(u => { + misskeyApi('users/show', Misskey.acct.parse(props.acct)).then(u => { user.value = u; }).catch(err => { error.value = err; @@ -83,7 +87,7 @@ const headerTabs = computed(() => user.value ? [{ }, { key: 'notes', title: i18n.ts.notes, - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', }, { key: 'activity', title: i18n.ts.activity, @@ -92,7 +96,7 @@ const headerTabs = computed(() => user.value ? [{ key: 'achievements', title: i18n.ts.achievements, icon: 'ph-trophy ph-bold ph-lg', -}] : []), ...($i && ($i.id === user.value.id)) || user.value.publicReactions ? [{ +}] : []), ...($i && ($i.id === user.value.id || $i.isAdmin || $i.isModerator)) || user.value.publicReactions ? [{ key: 'reactions', title: i18n.ts.reaction, icon: 'ph-smiley ph-bold ph-lg', @@ -122,15 +126,18 @@ const headerTabs = computed(() => user.value ? [{ icon: 'ph-code ph-bold ph-lg', }] : []); -definePageMetadata(computed(() => user.value ? { +definePageMetadata(() => ({ + title: i18n.ts.user, icon: 'ph-user ph-bold ph-lg', - title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, - subtitle: `@${getAcct(user.value)}`, - userName: user.value, - avatar: user.value, - path: `/@${user.value.username}`, - share: { - title: user.value.name, - }, -} : null)); + ...user.value ? { + title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, + subtitle: `@${getAcct(user.value)}`, + userName: user.value, + avatar: user.value, + path: `/@${user.value.username}`, + share: { + title: user.value.name, + }, + } : {}, +})); </script> diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue index c58a8abdfb..8f95ce2dc9 100644 --- a/packages/frontend/src/pages/user/lists.vue +++ b/packages/frontend/src/pages/user/lists.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue index 94ec80d05e..6375bf7d74 100644 --- a/packages/frontend/src/pages/user/pages.vue +++ b/packages/frontend/src/pages/user/pages.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/raw.vue b/packages/frontend/src/pages/user/raw.vue index ebe40d5860..ac18ad9392 100644 --- a/packages/frontend/src/pages/user/raw.vue +++ b/packages/frontend/src/pages/user/raw.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue index 916b6615d5..3671decc18 100644 --- a/packages/frontend/src/pages/user/reactions.vue +++ b/packages/frontend/src/pages/user/reactions.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 50f86a0ae2..255e07c3fa 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,7 @@ import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import misskeysvg from '/client-assets/sharkey.svg'; -import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; @@ -53,11 +53,11 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string return getProxiedImageUrl(instance.iconUrl, 'preview'); } -os.api('meta', { detail: true }).then(_meta => { +misskeyApi('meta', { detail: true }).then(_meta => { meta.value = _meta; }); -os.apiGet('federation/instances', { +misskeyApiGet('federation/instances', { sort: '+pubSub', limit: 20, }).then(_instances => { diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index c2f9d4e585..7d5861d2ae 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -40,6 +40,7 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import { host, version } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; import MkAnimBg from '@/components/MkAnimBg.vue'; @@ -52,7 +53,7 @@ function submit() { if (submitting.value) return; submitting.value = true; - os.api('admin/accounts/create', { + misskeyApi('admin/accounts/create', { username: username.value, password: password.value, }).then(res => { diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 2cbe0ed9b1..59f91e8b4c 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkMediaList :mediaList="note.files"/> </div> <div v-if="note.poll"> - <MkPoll :note="note" :readOnly="true"/> + <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> </div> </div> <MkReactionsViewer ref="reactionsViewer" :note="note"/> @@ -32,14 +32,14 @@ import { onUpdated, ref, shallowRef } from 'vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { getScrollContainer } from '@/scripts/scroll.js'; const notes = ref<Misskey.entities.Note[]>([]); const isScrolling = ref(false); const scrollEl = shallowRef<HTMLElement>(); -os.apiGet('notes/featured').then(_notes => { +misskeyApiGet('notes/featured').then(_notes => { notes.value = _notes; }); diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index 7f0af1b83e..9ba6a5885e 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,12 +16,12 @@ import * as Misskey from 'misskey-js'; import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@/config.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; const meta = ref<Misskey.entities.MetaResponse | null>(null); -os.api('meta', { detail: true }).then(res => { +misskeyApi('meta', { detail: true }).then(res => { meta.value = res; }); @@ -29,8 +29,8 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePageMetadata(() => ({ title: instanceName, icon: null, -}))); +})); </script> diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index b2254a0611..ac325e923f 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -8,11 +8,12 @@ import { onUnmounted, Ref, ref, watch } from 'vue'; import { BroadcastChannel } from 'broadcast-channel'; import { $i } from '@/account.js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { get, set } from '@/scripts/idb-proxy.js'; import { defaultStore } from '@/store.js'; import { useStream } from '@/stream.js'; import { deepClone } from '@/scripts/clone.js'; +import { deepMerge } from '@/scripts/merge.js'; type StateDef = Record<string, { where: 'account' | 'device' | 'deviceAccount'; @@ -80,6 +81,21 @@ export class Storage<T extends StateDef> { this.loaded = this.ready.then(() => this.load()); } + private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private mergeState<X>(value: X, def: X): X { + if (this.isPureObject(value) && this.isPureObject(def)) { + const merged = deepMerge(value, def); + + if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); + + return merged as X; + } + return value; + } + private async init(): Promise<void> { await this.migrate(); @@ -89,11 +105,11 @@ export class Storage<T extends StateDef> { for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { - this.reactiveState[k].value = this.state[k] = deviceState[k]; + this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - this.reactiveState[k].value = this.state[k] = registryCache[k]; + this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - this.reactiveState[k].value = this.state[k] = deviceAccountState[k]; + this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); } else { this.reactiveState[k].value = this.state[k] = v.default; if (_DEV_) console.log('Use default value', k, v.default); @@ -134,7 +150,7 @@ export class Storage<T extends StateDef> { window.setTimeout(async () => { await defaultStore.ready; - api('i/registry/get-all', { scope: ['client', this.key] }) + misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) .then(kvs => { const cache: Partial<T> = {}; for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { @@ -168,7 +184,7 @@ export class Storage<T extends StateDef> { this.reactiveState[key].value = this.state[key] = rawValue; return this.addIdbSetJob(async () => { - if (_DEV_) console.log(`set ${key} start`); + if (_DEV_) console.log(`set ${String(key)} start`); switch (this.def[key].where) { case 'device': { this.pizzaxChannel.postMessage({ @@ -199,7 +215,7 @@ export class Storage<T extends StateDef> { const cache = await get(this.registryCacheKeyName) || {}; cache[key] = rawValue; await set(this.registryCacheKeyName, cache); - await api('i/registry/set', { + await misskeyApi('i/registry/set', { scope: ['client', this.key], key: key.toString(), value: rawValue, @@ -207,7 +223,7 @@ export class Storage<T extends StateDef> { break; } } - if (_DEV_) console.log(`set ${key} complete`); + if (_DEV_) console.log(`set ${String(key)} complete`); }); } @@ -223,9 +239,12 @@ export class Storage<T extends StateDef> { /** * 特定のキーの、簡易的なgetter/setterを作ります - * 主にvue場で設定コントロールのmodelとして使う用 + * 主にvue上で設定コントロールのmodelとして使う用 */ - public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) { + public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]): { + get: () => T[K]['default']; + set: (value: T[K]['default']) => void; + } { const valueRef = ref(this.state[key]); const stop = watch(this.reactiveState[key], val => { diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 5e49af4858..743cadc36a 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; -import { createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import { inputText } from '@/os.js'; import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js'; @@ -19,19 +19,7 @@ export async function install(plugin: Plugin): Promise<void> { plugin: plugin, storageKey: 'plugins:' + plugin.id, }), { - in: (q): Promise<string> => { - return new Promise(ok => { - inputText({ - title: q, - }).then(({ canceled, result: a }) => { - if (canceled) { - ok(''); - } else { - ok(a); - } - }); - }); - }, + in: aiScriptReadline, out: (value): void => { console.log(value); }, diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts deleted file mode 100644 index b861afa9a3..0000000000 --- a/packages/frontend/src/router.ts +++ /dev/null @@ -1,558 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue'; -import { Router } from '@/nirax.js'; -import { $i, iAmModerator } from '@/account.js'; -import MkLoading from '@/pages/_loading_.vue'; -import MkError from '@/pages/_error_.vue'; - -export const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ - loader: loader, - loadingComponent: MkLoading, - errorComponent: MkError, -}); - -export const routes = [{ - path: '/@:initUser/pages/:initPageName/view-source', - component: page(() => import('./pages/page-editor/page-editor.vue')), -}, { - path: '/@:username/pages/:pageName', - component: page(() => import('./pages/page.vue')), -}, { - path: '/@:acct/following', - component: page(() => import('./pages/user/following.vue')), -}, { - path: '/@:acct/followers', - component: page(() => import('./pages/user/followers.vue')), -}, { - name: 'user', - path: '/@:acct/:page?', - component: page(() => import('./pages/user/index.vue')), -}, { - name: 'note', - path: '/notes/:noteId', - component: page(() => import('./pages/note.vue')), -}, { - name: 'list', - path: '/list/:listId', - component: page(() => import('./pages/list.vue')), -}, { - path: '/clips/:clipId', - component: page(() => import('./pages/clip.vue')), -}, { - path: '/instance-info/:host', - component: page(() => import('./pages/instance-info.vue')), -}, { - name: 'settings', - path: '/settings', - component: page(() => import('./pages/settings/index.vue')), - loginRequired: true, - children: [{ - path: '/profile', - name: 'profile', - component: page(() => import('./pages/settings/profile.vue')), - }, { - path: '/avatar-decoration', - name: 'avatarDecoration', - component: page(() => import('./pages/settings/avatar-decoration.vue')), - }, { - path: '/roles', - name: 'roles', - component: page(() => import('./pages/settings/roles.vue')), - }, { - path: '/privacy', - name: 'privacy', - component: page(() => import('./pages/settings/privacy.vue')), - }, { - path: '/emoji-picker', - name: 'emojiPicker', - component: page(() => import('./pages/settings/emoji-picker.vue')), - }, { - path: '/drive', - name: 'drive', - component: page(() => import('./pages/settings/drive.vue')), - }, { - path: '/drive/cleaner', - name: 'drive', - component: page(() => import('./pages/settings/drive-cleaner.vue')), - }, { - path: '/notifications', - name: 'notifications', - component: page(() => import('./pages/settings/notifications.vue')), - }, { - path: '/email', - name: 'email', - component: page(() => import('./pages/settings/email.vue')), - }, { - path: '/security', - name: 'security', - component: page(() => import('./pages/settings/security.vue')), - }, { - path: '/general', - name: 'general', - component: page(() => import('./pages/settings/general.vue')), - }, { - path: '/theme/install', - name: 'theme', - component: page(() => import('./pages/settings/theme.install.vue')), - }, { - path: '/theme/manage', - name: 'theme', - component: page(() => import('./pages/settings/theme.manage.vue')), - }, { - path: '/theme', - name: 'theme', - component: page(() => import('./pages/settings/theme.vue')), - }, { - path: '/navbar', - name: 'navbar', - component: page(() => import('./pages/settings/navbar.vue')), - }, { - path: '/statusbar', - name: 'statusbar', - component: page(() => import('./pages/settings/statusbar.vue')), - }, { - path: '/sounds', - name: 'sounds', - component: page(() => import('./pages/settings/sounds.vue')), - }, { - path: '/plugin/install', - name: 'plugin', - component: page(() => import('./pages/settings/plugin.install.vue')), - }, { - path: '/plugin', - name: 'plugin', - component: page(() => import('./pages/settings/plugin.vue')), - }, { - path: '/import-export', - name: 'import-export', - component: page(() => import('./pages/settings/import-export.vue')), - }, { - path: '/mute-block', - name: 'mute-block', - component: page(() => import('./pages/settings/mute-block.vue')), - }, { - path: '/api', - name: 'api', - component: page(() => import('./pages/settings/api.vue')), - }, { - path: '/apps', - name: 'api', - component: page(() => import('./pages/settings/apps.vue')), - }, { - path: '/webhook/edit/:webhookId', - name: 'webhook', - component: page(() => import('./pages/settings/webhook.edit.vue')), - }, { - path: '/webhook/new', - name: 'webhook', - component: page(() => import('./pages/settings/webhook.new.vue')), - }, { - path: '/webhook', - name: 'webhook', - component: page(() => import('./pages/settings/webhook.vue')), - }, { - path: '/deck', - name: 'deck', - component: page(() => import('./pages/settings/deck.vue')), - }, { - path: '/preferences-backups', - name: 'preferences-backups', - component: page(() => import('./pages/settings/preferences-backups.vue')), - }, { - path: '/migration', - name: 'migration', - component: page(() => import('./pages/settings/migration.vue')), - }, { - path: '/custom-css', - name: 'general', - component: page(() => import('./pages/settings/custom-css.vue')), - }, { - path: '/accounts', - name: 'profile', - component: page(() => import('./pages/settings/accounts.vue')), - }, { - path: '/other', - name: 'other', - component: page(() => import('./pages/settings/other.vue')), - }, { - path: '/', - component: page(() => import('./pages/_empty_.vue')), - }], -}, { - path: '/reset-password/:token?', - component: page(() => import('./pages/reset-password.vue')), -}, { - path: '/signup-complete/:code', - component: page(() => import('./pages/signup-complete.vue')), -}, { - path: '/announcements', - component: page(() => import('./pages/announcements.vue')), -}, { - path: '/about', - component: page(() => import('./pages/about.vue')), - hash: 'initialTab', -}, { - path: '/about-sharkey', - component: page(() => import('./pages/about-sharkey.vue')), -}, { - path: '/invite', - name: 'invite', - component: page(() => import('./pages/invite.vue')), -}, { - path: '/ads', - component: page(() => import('./pages/ads.vue')), -}, { - path: '/theme-editor', - component: page(() => import('./pages/theme-editor.vue')), - loginRequired: true, -}, { - path: '/roles/:role', - component: page(() => import('./pages/role.vue')), -}, { - path: '/user-tags/:tag', - component: page(() => import('./pages/user-tag.vue')), -}, { - path: '/explore', - component: page(() => import('./pages/explore.vue')), - hash: 'initialTab', -}, { - path: '/search', - component: page(() => import('./pages/search.vue')), - query: { - q: 'query', - channel: 'channel', - type: 'type', - origin: 'origin', - }, -}, { - path: '/authorize-follow', - component: page(() => import('./pages/follow.vue')), - loginRequired: true, -}, { - path: '/share', - component: page(() => import('./pages/share.vue')), - loginRequired: true, -}, { - path: '/api-console', - component: page(() => import('./pages/api-console.vue')), - loginRequired: true, -}, { - path: '/scratchpad', - component: page(() => import('./pages/scratchpad.vue')), -}, { - path: '/auth/:token', - component: page(() => import('./pages/auth.vue')), -}, { - path: '/miauth/:session', - component: page(() => import('./pages/miauth.vue')), - query: { - callback: 'callback', - name: 'name', - icon: 'icon', - permission: 'permission', - }, -}, { - path: '/tags/:tag', - component: page(() => import('./pages/tag.vue')), -}, { - path: '/pages/new', - component: page(() => import('./pages/page-editor/page-editor.vue')), - loginRequired: true, -}, { - path: '/pages/edit/:initPageId', - component: page(() => import('./pages/page-editor/page-editor.vue')), - loginRequired: true, -}, { - path: '/pages', - component: page(() => import('./pages/pages.vue')), -}, { - path: '/play/:id/edit', - component: page(() => import('./pages/flash/flash-edit.vue')), - loginRequired: true, -}, { - path: '/play/new', - component: page(() => import('./pages/flash/flash-edit.vue')), - loginRequired: true, -}, { - path: '/play/:id', - component: page(() => import('./pages/flash/flash.vue')), -}, { - path: '/play', - component: page(() => import('./pages/flash/flash-index.vue')), -}, { - path: '/gallery/:postId/edit', - component: page(() => import('./pages/gallery/edit.vue')), - loginRequired: true, -}, { - path: '/gallery/new', - component: page(() => import('./pages/gallery/edit.vue')), - loginRequired: true, -}, { - path: '/gallery/:postId', - component: page(() => import('./pages/gallery/post.vue')), -}, { - path: '/gallery', - component: page(() => import('./pages/gallery/index.vue')), -}, { - path: '/channels/:channelId/edit', - component: page(() => import('./pages/channel-editor.vue')), - loginRequired: true, -}, { - path: '/channels/new', - component: page(() => import('./pages/channel-editor.vue')), - loginRequired: true, -}, { - path: '/channels/:channelId', - component: page(() => import('./pages/channel.vue')), -}, { - path: '/channels', - component: page(() => import('./pages/channels.vue')), -}, { - path: '/avatar-decorations', - name: 'avatarDecorations', - component: page(() => import('./pages/avatar-decorations.vue')), -}, { - path: '/custom-emojis-manager', - component: page(() => import('./pages/custom-emojis-manager.vue')), -}, { - path: '/registry/keys/:domain/:path(*)?', - component: page(() => import('./pages/registry.keys.vue')), -}, { - path: '/registry/value/:domain/:path(*)?', - component: page(() => import('./pages/registry.value.vue')), -}, { - path: '/registry', - component: page(() => import('./pages/registry.vue')), -}, { - path: '/install-extentions', - component: page(() => import('./pages/install-extentions.vue')), - loginRequired: true, -}, { - path: '/admin/user/:userId', - component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')), -}, { - path: '/admin/file/:fileId', - component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')), -}, { - path: '/admin', - component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')), - children: [{ - path: '/overview', - name: 'overview', - component: page(() => import('./pages/admin/overview.vue')), - }, { - path: '/users', - name: 'users', - component: page(() => import('./pages/admin/users.vue')), - }, { - path: '/emojis', - name: 'emojis', - component: page(() => import('./pages/custom-emojis-manager.vue')), - }, { - path: '/avatar-decorations', - name: 'avatarDecorations', - component: page(() => import('./pages/avatar-decorations.vue')), - }, { - path: '/queue', - name: 'queue', - component: page(() => import('./pages/admin/queue.vue')), - }, { - path: '/files', - name: 'files', - component: page(() => import('./pages/admin/files.vue')), - }, { - path: '/federation', - name: 'federation', - component: page(() => import('./pages/admin/federation.vue')), - }, { - path: '/announcements', - name: 'announcements', - component: page(() => import('./pages/admin/announcements.vue')), - }, { - path: '/ads', - name: 'ads', - component: page(() => import('./pages/admin/ads.vue')), - }, { - path: '/roles/:id/edit', - name: 'roles', - component: page(() => import('./pages/admin/roles.edit.vue')), - }, { - path: '/roles/new', - name: 'roles', - component: page(() => import('./pages/admin/roles.edit.vue')), - }, { - path: '/roles/:id', - name: 'roles', - component: page(() => import('./pages/admin/roles.role.vue')), - }, { - path: '/roles', - name: 'roles', - component: page(() => import('./pages/admin/roles.vue')), - }, { - path: '/database', - name: 'database', - component: page(() => import('./pages/admin/database.vue')), - }, { - path: '/abuses', - name: 'abuses', - component: page(() => import('./pages/admin/abuses.vue')), - }, { - path: '/modlog', - name: 'modlog', - component: page(() => import('./pages/admin/modlog.vue')), - }, { - path: '/settings', - name: 'settings', - component: page(() => import('./pages/admin/settings.vue')), - }, { - path: '/branding', - name: 'branding', - component: page(() => import('./pages/admin/branding.vue')), - }, { - path: '/moderation', - name: 'moderation', - component: page(() => import('./pages/admin/moderation.vue')), - }, { - path: '/email-settings', - name: 'email-settings', - component: page(() => import('./pages/admin/email-settings.vue')), - }, { - path: '/object-storage', - name: 'object-storage', - component: page(() => import('./pages/admin/object-storage.vue')), - }, { - path: '/security', - name: 'security', - component: page(() => import('./pages/admin/security.vue')), - }, { - path: '/relays', - name: 'relays', - component: page(() => import('./pages/admin/relays.vue')), - }, { - path: '/instance-block', - name: 'instance-block', - component: page(() => import('./pages/admin/instance-block.vue')), - }, { - path: '/proxy-account', - name: 'proxy-account', - component: page(() => import('./pages/admin/proxy-account.vue')), - }, { - path: '/external-services', - name: 'external-services', - component: page(() => import('./pages/admin/external-services.vue')), - }, { - path: '/other-settings', - name: 'other-settings', - component: page(() => import('./pages/admin/other-settings.vue')), - }, { - path: '/server-rules', - name: 'server-rules', - component: page(() => import('./pages/admin/server-rules.vue')), - }, { - path: '/invites', - name: 'invites', - component: page(() => import('./pages/admin/invites.vue')), - }, { - path: '/approvals', - name: 'approvals', - component: page(() => import('./pages/admin/approvals.vue')), - }, { - path: '/', - component: page(() => import('./pages/_empty_.vue')), - }], -}, { - path: '/my/notifications', - component: page(() => import('./pages/notifications.vue')), - loginRequired: true, -}, { - path: '/my/favorites', - component: page(() => import('./pages/favorites.vue')), - loginRequired: true, -}, { - path: '/my/achievements', - component: page(() => import('./pages/achievements.vue')), - loginRequired: true, -}, { - path: '/my/drive/folder/:folder', - component: page(() => import('./pages/drive.vue')), - loginRequired: true, -}, { - path: '/my/drive', - component: page(() => import('./pages/drive.vue')), - loginRequired: true, -}, { - path: '/my/drive/file/:fileId', - component: page(() => import('./pages/drive.file.vue')), - loginRequired: true, -}, { - path: '/my/follow-requests', - component: page(() => import('./pages/follow-requests.vue')), - loginRequired: true, -}, { - path: '/my/lists/:listId', - component: page(() => import('./pages/my-lists/list.vue')), - loginRequired: true, -}, { - path: '/my/lists', - component: page(() => import('./pages/my-lists/index.vue')), - loginRequired: true, -}, { - path: '/my/clips', - component: page(() => import('./pages/my-clips/index.vue')), - loginRequired: true, -}, { - path: '/my/antennas/create', - component: page(() => import('./pages/my-antennas/create.vue')), - loginRequired: true, -}, { - path: '/my/antennas/:antennaId', - component: page(() => import('./pages/my-antennas/edit.vue')), - loginRequired: true, -}, { - path: '/my/antennas', - component: page(() => import('./pages/my-antennas/index.vue')), - loginRequired: true, -}, { - path: '/timeline/list/:listId', - component: page(() => import('./pages/user-list-timeline.vue')), - loginRequired: true, -}, { - path: '/timeline/antenna/:antennaId', - component: page(() => import('./pages/antenna-timeline.vue')), - loginRequired: true, -}, { - path: '/clicker', - component: page(() => import('./pages/clicker.vue')), - loginRequired: true, -}, { - path: '/timeline', - component: page(() => import('./pages/timeline.vue')), -}, { - name: 'index', - path: '/', - component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')), - globalCacheKey: 'index', -}, { - path: '/:(*)', - component: page(() => import('./pages/not-found.vue')), -}]; - -export const mainRouter = new Router(routes, location.pathname + location.search + location.hash, !!$i, page(() => import('@/pages/not-found.vue'))); - -window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); - -mainRouter.addListener('push', ctx => { - window.history.pushState({ key: ctx.key }, '', ctx.path); -}); - -window.addEventListener('popstate', (event) => { - mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); -}); - -export function useRouter(): Router { - return inject<Router | null>('router', null) ?? mainRouter; -} diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts new file mode 100644 index 0000000000..c5fc28a345 --- /dev/null +++ b/packages/frontend/src/router/definition.ts @@ -0,0 +1,606 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { App, AsyncComponentLoader, defineAsyncComponent, provide } from 'vue'; +import type { RouteDef } from '@/nirax.js'; +import { IRouter, Router } from '@/nirax.js'; +import { $i, iAmModerator } from '@/account.js'; +import MkLoading from '@/pages/_loading_.vue'; +import MkError from '@/pages/_error_.vue'; +import { setMainRouter } from '@/router/main.js'; + +const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ + loader: loader, + loadingComponent: MkLoading, + errorComponent: MkError, +}); + +const routes: RouteDef[] = [{ + path: '/@:initUser/pages/:initPageName/view-source', + component: page(() => import('@/pages/page-editor/page-editor.vue')), +}, { + path: '/@:username/pages/:pageName', + component: page(() => import('@/pages/page.vue')), +}, { + path: '/@:acct/following', + component: page(() => import('@/pages/user/following.vue')), +}, { + path: '/@:acct/followers', + component: page(() => import('@/pages/user/followers.vue')), +}, { + name: 'user', + path: '/@:acct/:page?', + component: page(() => import('@/pages/user/index.vue')), +}, { + name: 'note', + path: '/notes/:noteId', + component: page(() => import('@/pages/note.vue')), +}, { + name: 'list', + path: '/list/:listId', + component: page(() => import('@/pages/list.vue')), +}, { + path: '/clips/:clipId', + component: page(() => import('@/pages/clip.vue')), +}, { + path: '/instance-info/:host', + component: page(() => import('@/pages/instance-info.vue')), +}, { + name: 'settings', + path: '/settings', + component: page(() => import('@/pages/settings/index.vue')), + loginRequired: true, + children: [{ + path: '/profile', + name: 'profile', + component: page(() => import('@/pages/settings/profile.vue')), + }, { + path: '/avatar-decoration', + name: 'avatarDecoration', + component: page(() => import('@/pages/settings/avatar-decoration.vue')), + }, { + path: '/roles', + name: 'roles', + component: page(() => import('@/pages/settings/roles.vue')), + }, { + path: '/privacy', + name: 'privacy', + component: page(() => import('@/pages/settings/privacy.vue')), + }, { + path: '/emoji-picker', + name: 'emojiPicker', + component: page(() => import('@/pages/settings/emoji-picker.vue')), + }, { + path: '/drive', + name: 'drive', + component: page(() => import('@/pages/settings/drive.vue')), + }, { + path: '/drive/cleaner', + name: 'drive', + component: page(() => import('@/pages/settings/drive-cleaner.vue')), + }, { + path: '/notifications', + name: 'notifications', + component: page(() => import('@/pages/settings/notifications.vue')), + }, { + path: '/email', + name: 'email', + component: page(() => import('@/pages/settings/email.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('@/pages/settings/security.vue')), + }, { + path: '/general', + name: 'general', + component: page(() => import('@/pages/settings/general.vue')), + }, { + path: '/theme/install', + name: 'theme', + component: page(() => import('@/pages/settings/theme.install.vue')), + }, { + path: '/theme/manage', + name: 'theme', + component: page(() => import('@/pages/settings/theme.manage.vue')), + }, { + path: '/theme', + name: 'theme', + component: page(() => import('@/pages/settings/theme.vue')), + }, { + path: '/navbar', + name: 'navbar', + component: page(() => import('@/pages/settings/navbar.vue')), + }, { + path: '/statusbar', + name: 'statusbar', + component: page(() => import('@/pages/settings/statusbar.vue')), + }, { + path: '/sounds', + name: 'sounds', + component: page(() => import('@/pages/settings/sounds.vue')), + }, { + path: '/plugin/install', + name: 'plugin', + component: page(() => import('@/pages/settings/plugin.install.vue')), + }, { + path: '/plugin', + name: 'plugin', + component: page(() => import('@/pages/settings/plugin.vue')), + }, { + path: '/import-export', + name: 'import-export', + component: page(() => import('@/pages/settings/import-export.vue')), + }, { + path: '/mute-block', + name: 'mute-block', + component: page(() => import('@/pages/settings/mute-block.vue')), + }, { + path: '/api', + name: 'api', + component: page(() => import('@/pages/settings/api.vue')), + }, { + path: '/apps', + name: 'api', + component: page(() => import('@/pages/settings/apps.vue')), + }, { + path: '/webhook/edit/:webhookId', + name: 'webhook', + component: page(() => import('@/pages/settings/webhook.edit.vue')), + }, { + path: '/webhook/new', + name: 'webhook', + component: page(() => import('@/pages/settings/webhook.new.vue')), + }, { + path: '/webhook', + name: 'webhook', + component: page(() => import('@/pages/settings/webhook.vue')), + }, { + path: '/deck', + name: 'deck', + component: page(() => import('@/pages/settings/deck.vue')), + }, { + path: '/preferences-backups', + name: 'preferences-backups', + component: page(() => import('@/pages/settings/preferences-backups.vue')), + }, { + path: '/migration', + name: 'migration', + component: page(() => import('@/pages/settings/migration.vue')), + }, { + path: '/custom-css', + name: 'general', + component: page(() => import('@/pages/settings/custom-css.vue')), + }, { + path: '/accounts', + name: 'profile', + component: page(() => import('@/pages/settings/accounts.vue')), + }, { + path: '/other', + name: 'other', + component: page(() => import('@/pages/settings/other.vue')), + }, { + path: '/', + component: page(() => import('@/pages/_empty_.vue')), + }], +}, { + path: '/reset-password/:token?', + component: page(() => import('@/pages/reset-password.vue')), +}, { + path: '/signup-complete/:code', + component: page(() => import('@/pages/signup-complete.vue')), +}, { + path: '/announcements', + component: page(() => import('@/pages/announcements.vue')), +}, { + path: '/about', + component: page(() => import('@/pages/about.vue')), + hash: 'initialTab', +}, { + path: '/about-sharkey', + component: page(() => import('@/pages/about-sharkey.vue')), +}, { + path: '/invite', + name: 'invite', + component: page(() => import('@/pages/invite.vue')), +}, { + path: '/ads', + component: page(() => import('@/pages/ads.vue')), +}, { + path: '/theme-editor', + component: page(() => import('@/pages/theme-editor.vue')), + loginRequired: true, +}, { + path: '/roles/:role', + component: page(() => import('@/pages/role.vue')), +}, { + path: '/user-tags/:tag', + component: page(() => import('@/pages/user-tag.vue')), +}, { + path: '/explore', + component: page(() => import('@/pages/explore.vue')), + hash: 'initialTab', +}, { + path: '/search', + component: page(() => import('@/pages/search.vue')), + query: { + q: 'query', + channel: 'channel', + type: 'type', + origin: 'origin', + }, +}, { + path: '/authorize-follow', + component: page(() => import('@/pages/follow.vue')), + loginRequired: true, +}, { + path: '/share', + component: page(() => import('@/pages/share.vue')), + loginRequired: true, +}, { + path: '/api-console', + component: page(() => import('@/pages/api-console.vue')), + loginRequired: true, +}, { + path: '/scratchpad', + component: page(() => import('@/pages/scratchpad.vue')), +}, { + path: '/auth/:token', + component: page(() => import('@/pages/auth.vue')), +}, { + path: '/miauth/:session', + component: page(() => import('@/pages/miauth.vue')), + query: { + callback: 'callback', + name: 'name', + icon: 'icon', + permission: 'permission', + }, +}, { + path: '/oauth/authorize', + component: page(() => import('@/pages/oauth.vue')), +}, { + path: '/tags/:tag', + component: page(() => import('@/pages/tag.vue')), +}, { + path: '/pages/new', + component: page(() => import('@/pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages/edit/:initPageId', + component: page(() => import('@/pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages', + component: page(() => import('@/pages/pages.vue')), +}, { + path: '/play/:id/edit', + component: page(() => import('@/pages/flash/flash-edit.vue')), + loginRequired: true, +}, { + path: '/play/new', + component: page(() => import('@/pages/flash/flash-edit.vue')), + loginRequired: true, +}, { + path: '/play/:id', + component: page(() => import('@/pages/flash/flash.vue')), +}, { + path: '/play', + component: page(() => import('@/pages/flash/flash-index.vue')), +}, { + path: '/gallery/:postId/edit', + component: page(() => import('@/pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/new', + component: page(() => import('@/pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/:postId', + component: page(() => import('@/pages/gallery/post.vue')), +}, { + path: '/gallery', + component: page(() => import('@/pages/gallery/index.vue')), +}, { + path: '/channels/:channelId/edit', + component: page(() => import('@/pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/new', + component: page(() => import('@/pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/:channelId', + component: page(() => import('@/pages/channel.vue')), +}, { + path: '/channels', + component: page(() => import('@/pages/channels.vue')), +}, { + path: '/custom-emojis-manager', + component: page(() => import('@/pages/custom-emojis-manager.vue')), +}, { + path: '/avatar-decorations', + name: 'avatarDecorations', + component: page(() => import('@/pages/avatar-decorations.vue')), +}, { + path: '/registry/keys/:domain/:path(*)?', + component: page(() => import('@/pages/registry.keys.vue')), +}, { + path: '/registry/value/:domain/:path(*)?', + component: page(() => import('@/pages/registry.value.vue')), +}, { + path: '/registry', + component: page(() => import('@/pages/registry.vue')), +}, { + path: '/install-extentions', + redirect: '/install-extensions', + loginRequired: true, +}, { + path: '/install-extensions', + component: page(() => import('@/pages/install-extensions.vue')), + loginRequired: true, +}, { + path: '/admin/user/:userId', + component: iAmModerator ? page(() => import('@/pages/admin-user.vue')) : page(() => import('@/pages/not-found.vue')), +}, { + path: '/admin/file/:fileId', + component: iAmModerator ? page(() => import('@/pages/admin-file.vue')) : page(() => import('@/pages/not-found.vue')), +}, { + path: '/admin', + component: iAmModerator ? page(() => import('@/pages/admin/index.vue')) : page(() => import('@/pages/not-found.vue')), + children: [{ + path: '/overview', + name: 'overview', + component: page(() => import('@/pages/admin/overview.vue')), + }, { + path: '/users', + name: 'users', + component: page(() => import('@/pages/admin/users.vue')), + }, { + path: '/emojis', + name: 'emojis', + component: page(() => import('@/pages/custom-emojis-manager.vue')), + }, { + path: '/avatar-decorations', + name: 'avatarDecorations', + component: page(() => import('@/pages/avatar-decorations.vue')), + }, { + path: '/queue', + name: 'queue', + component: page(() => import('@/pages/admin/queue.vue')), + }, { + path: '/files', + name: 'files', + component: page(() => import('@/pages/admin/files.vue')), + }, { + path: '/federation', + name: 'federation', + component: page(() => import('@/pages/admin/federation.vue')), + }, { + path: '/announcements', + name: 'announcements', + component: page(() => import('@/pages/admin/announcements.vue')), + }, { + path: '/ads', + name: 'ads', + component: page(() => import('@/pages/admin/ads.vue')), + }, { + path: '/roles/:id/edit', + name: 'roles', + component: page(() => import('@/pages/admin/roles.edit.vue')), + }, { + path: '/roles/new', + name: 'roles', + component: page(() => import('@/pages/admin/roles.edit.vue')), + }, { + path: '/roles/:id', + name: 'roles', + component: page(() => import('@/pages/admin/roles.role.vue')), + }, { + path: '/roles', + name: 'roles', + component: page(() => import('@/pages/admin/roles.vue')), + }, { + path: '/database', + name: 'database', + component: page(() => import('@/pages/admin/database.vue')), + }, { + path: '/abuses', + name: 'abuses', + component: page(() => import('@/pages/admin/abuses.vue')), + }, { + path: '/modlog', + name: 'modlog', + component: page(() => import('@/pages/admin/modlog.vue')), + }, { + path: '/settings', + name: 'settings', + component: page(() => import('@/pages/admin/settings.vue')), + }, { + path: '/branding', + name: 'branding', + component: page(() => import('@/pages/admin/branding.vue')), + }, { + path: '/moderation', + name: 'moderation', + component: page(() => import('@/pages/admin/moderation.vue')), + }, { + path: '/email-settings', + name: 'email-settings', + component: page(() => import('@/pages/admin/email-settings.vue')), + }, { + path: '/object-storage', + name: 'object-storage', + component: page(() => import('@/pages/admin/object-storage.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('@/pages/admin/security.vue')), + }, { + path: '/relays', + name: 'relays', + component: page(() => import('@/pages/admin/relays.vue')), + }, { + path: '/instance-block', + name: 'instance-block', + component: page(() => import('@/pages/admin/instance-block.vue')), + }, { + path: '/proxy-account', + name: 'proxy-account', + component: page(() => import('@/pages/admin/proxy-account.vue')), + }, { + path: '/external-services', + name: 'external-services', + component: page(() => import('@/pages/admin/external-services.vue')), + }, { + path: '/other-settings', + name: 'other-settings', + component: page(() => import('@/pages/admin/other-settings.vue')), + }, { + path: '/server-rules', + name: 'server-rules', + component: page(() => import('@/pages/admin/server-rules.vue')), + }, { + path: '/invites', + name: 'invites', + component: page(() => import('@/pages/admin/invites.vue')), + }, { + path: '/approvals', + name: 'approvals', + component: page(() => import('@/pages/admin/approvals.vue')), + }, { + path: '/', + component: page(() => import('@/pages/_empty_.vue')), + }], +}, { + path: '/my/notifications', + component: page(() => import('@/pages/notifications.vue')), + loginRequired: true, +}, { + path: '/my/favorites', + component: page(() => import('@/pages/favorites.vue')), + loginRequired: true, +}, { + path: '/my/achievements', + component: page(() => import('@/pages/achievements.vue')), + loginRequired: true, +}, { + path: '/my/drive/folder/:folder', + component: page(() => import('@/pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/drive', + component: page(() => import('@/pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/drive/file/:fileId', + component: page(() => import('@/pages/drive.file.vue')), + loginRequired: true, +}, { + path: '/my/follow-requests', + component: page(() => import('@/pages/follow-requests.vue')), + loginRequired: true, +}, { + path: '/my/lists/:listId', + component: page(() => import('@/pages/my-lists/list.vue')), + loginRequired: true, +}, { + path: '/my/lists', + component: page(() => import('@/pages/my-lists/index.vue')), + loginRequired: true, +}, { + path: '/my/clips', + component: page(() => import('@/pages/my-clips/index.vue')), + loginRequired: true, +}, { + path: '/my/antennas/create', + component: page(() => import('@/pages/my-antennas/create.vue')), + loginRequired: true, +}, { + path: '/my/antennas/:antennaId', + component: page(() => import('@/pages/my-antennas/edit.vue')), + loginRequired: true, +}, { + path: '/my/antennas', + component: page(() => import('@/pages/my-antennas/index.vue')), + loginRequired: true, +}, { + path: '/timeline/list/:listId', + component: page(() => import('@/pages/user-list-timeline.vue')), + loginRequired: true, +}, { + path: '/timeline/antenna/:antennaId', + component: page(() => import('@/pages/antenna-timeline.vue')), + loginRequired: true, +}, { + path: '/clicker', + component: page(() => import('@/pages/clicker.vue')), + loginRequired: true, +}, { + path: '/games', + component: page(() => import('@/pages/games.vue')), + loginRequired: false, +}, { + path: '/bubble-game', + component: page(() => import('@/pages/drop-and-fusion.vue')), + loginRequired: true, +}, { + path: '/reversi', + component: page(() => import('@/pages/reversi/index.vue')), + loginRequired: false, +}, { + path: '/reversi/g/:gameId', + component: page(() => import('@/pages/reversi/game.vue')), + loginRequired: false, +}, { + path: '/timeline', + component: page(() => import('@/pages/timeline.vue')), +}, { + name: 'index', + path: '/', + component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')), + globalCacheKey: 'index', +}, { + // テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする + path: '/redirect-test', + redirect: $i ? `@${$i.username}` : '/', + loginRequired: true, +}, { + path: '/:(*)', + component: page(() => import('@/pages/not-found.vue')), +}]; + +function createRouterImpl(path: string): IRouter { + return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue'))); +} + +/** + * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。 + * また、{@link Router}のインスタンスを作成するためのファクトリも{@link provide}経由で公開する(`routerFactory`というキーで取得可能) + */ +export function setupRouter(app: App) { + app.provide('routerFactory', createRouterImpl); + + const mainRouter = createRouterImpl(location.pathname + location.search + location.hash); + + window.addEventListener('popstate', (event) => { + mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); + }); + + mainRouter.addListener('push', ctx => { + window.history.pushState({ key: ctx.key }, '', ctx.path); + }); + + mainRouter.addListener('same', () => { + window.scroll({ top: 0, behavior: 'smooth' }); + }); + + mainRouter.addListener('replace', ctx => { + window.history.replaceState({ key: ctx.key }, '', ctx.path); + }); + + mainRouter.init(); + + setMainRouter(mainRouter); +} diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts new file mode 100644 index 0000000000..7a3fde131e --- /dev/null +++ b/packages/frontend/src/router/main.ts @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ShallowRef } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import { IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js'; + +function getMainRouter(): IRouter { + const router = mainRouterHolder; + if (!router) { + throw new Error('mainRouter is not found.'); + } + + return router; +} + +/** + * メインルータを設定する。一度設定すると、それ以降は変更できない。 + * {@link setupRouter}から呼び出されることのみを想定している。 + */ +export function setMainRouter(router: IRouter) { + if (mainRouterHolder) { + throw new Error('mainRouter is already exists.'); + } + + mainRouterHolder = router; +} + +/** + * {@link mainRouter}用のプロキシ実装。 + * {@link mainRouter}は起動シーケンスの一部にて初期化されるため、僅かにundefinedになる期間がある。 + * その僅かな期間のためだけに型をundefined込みにしたくないのでこのクラスを緩衝材として使用する。 + */ +class MainRouterProxy implements IRouter { + private supplier: () => IRouter; + + constructor(supplier: () => IRouter) { + this.supplier = supplier; + } + + get current(): Resolved { + return this.supplier().current; + } + + get currentRef(): ShallowRef<Resolved> { + return this.supplier().currentRef; + } + + get currentRoute(): ShallowRef<RouteDef> { + return this.supplier().currentRoute; + } + + get navHook(): ((path: string, flag?: any) => boolean) | null { + return this.supplier().navHook; + } + + set navHook(value) { + this.supplier().navHook = value; + } + + getCurrentKey(): string { + return this.supplier().getCurrentKey(); + } + + getCurrentPath(): any { + return this.supplier().getCurrentPath(); + } + + push(path: string, flag?: any): void { + this.supplier().push(path, flag); + } + + replace(path: string, key?: string | null): void { + this.supplier().replace(path, key); + } + + resolve(path: string): Resolved | null { + return this.supplier().resolve(path); + } + + init(): void { + this.supplier().init(); + } + + eventNames(): Array<EventEmitter.EventNames<RouterEvent>> { + return this.supplier().eventNames(); + } + + listeners<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + ): Array<EventEmitter.EventListener<RouterEvent, T>> { + return this.supplier().listeners(event); + } + + listenerCount( + event: EventEmitter.EventNames<RouterEvent>, + ): number { + return this.supplier().listenerCount(event); + } + + emit<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + ...args: EventEmitter.EventArgs<RouterEvent, T> + ): boolean { + return this.supplier().emit(event, ...args); + } + + on<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + ): this { + this.supplier().on(event, fn, context); + return this; + } + + addListener<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + ): this { + this.supplier().addListener(event, fn, context); + return this; + } + + once<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + ): this { + this.supplier().once(event, fn, context); + return this; + } + + removeListener<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn?: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + once?: boolean, + ): this { + this.supplier().removeListener(event, fn, context, once); + return this; + } + + off<T extends EventEmitter.EventNames<RouterEvent>>( + event: T, + fn?: EventEmitter.EventListener<RouterEvent, T>, + context?: any, + once?: boolean, + ): this { + this.supplier().off(event, fn, context, once); + return this; + } + + removeAllListeners( + event?: EventEmitter.EventNames<RouterEvent>, + ): this { + this.supplier().removeAllListeners(event); + return this; + } +} + +let mainRouterHolder: IRouter | null = null; + +export const mainRouter: IRouter = new MainRouterProxy(getMainRouter); diff --git a/packages/frontend/src/router/supplier.ts b/packages/frontend/src/router/supplier.ts new file mode 100644 index 0000000000..7da236f4e7 --- /dev/null +++ b/packages/frontend/src/router/supplier.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { inject } from 'vue'; +import { IRouter, Router } from '@/nirax.js'; +import { mainRouter } from '@/router/main.js'; + +/** + * メインの{@link Router}を取得する。 + * あらかじめ{@link setupRouter}を実行しておく必要がある({@link provide}により{@link IRouter}のインスタンスを注入可能であるならばこの限りではない) + */ +export function useRouter(): IRouter { + return inject<Router | null>('router', null) ?? mainRouter; +} + +/** + * 任意の{@link Router}を取得するためのファクトリを取得する。 + * あらかじめ{@link setupRouter}を実行しておく必要がある。 + */ +export function useRouterFactory(): (path: string) => IRouter { + const factory = inject<(path: string) => IRouter>('routerFactory'); + if (!factory) { + console.error('routerFactory is not defined.'); + throw new Error('routerFactory is not defined.'); + } + + return factory; +} diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts index e7585fcf81..f5d0ab559f 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/scripts/achievements.ts @@ -1,9 +1,9 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i } from '@/account.js'; export const ACHIEVEMENT_TYPES = [ @@ -83,6 +83,8 @@ export const ACHIEVEMENT_TYPES = [ 'brainDiver', 'smashTestNotificationButton', 'tutorialCompleted', + 'bubbleGameExplodingHead', + 'bubbleGameDoubleExplodingHead', ] as const; export const ACHIEVEMENT_BADGES = { @@ -466,6 +468,16 @@ export const ACHIEVEMENT_BADGES = { bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, + 'bubbleGameExplodingHead': { + img: '/fluent-emoji/1f92f.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'bronze', + }, + 'bubbleGameDoubleExplodingHead': { + img: '/fluent-emoji/1f92f.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> } as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], { img: string; @@ -489,7 +501,7 @@ export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) { window.setTimeout(() => { claimingQueue.delete(type); }, 500); - os.api('i/claim-achievement', { name: type }); + misskeyApi('i/claim-achievement', { name: type }); } if (_DEV_) { diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index 038ae23109..98a0c61752 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -1,16 +1,27 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { utils, values } from '@syuilo/aiscript'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i } from '@/account.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; import { url, lang } from '@/config.js'; import { nyaize } from '@/scripts/nyaize.js'; +export function aiScriptReadline(q: string): Promise<string> { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ result: a }) => { + ok(a ?? ''); + }); + }); +} + export function createAiScriptEnv(opts) { return { USER_ID: $i ? values.STR($i.id) : values.NULL, @@ -44,7 +55,7 @@ export function createAiScriptEnv(opts) { if (typeof token.value !== 'string') throw new Error('invalid token'); } const actualToken: string|null = token?.value ?? opts.token ?? null; - return os.api(ep.value, utils.valToJs(param), actualToken).then(res => { + return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => { return utils.jsToVal(res); }, err => { return values.ERROR('request_failed', utils.jsToVal(err)); diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 08ba1e6d9b..f2493264d3 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -218,7 +218,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't }; } -function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'type'> { +function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> { utils.assertObject(def); const text = def.value.get('text'); @@ -241,7 +241,7 @@ function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'typ color: color?.value, font: font?.value, onClickEv: (evId: string) => { - if (onClickEv) call(onClickEv, values.STR(evId)); + if (onClickEv) call(onClickEv, [values.STR(evId)]); }, }; } diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts index 082703a450..b3d76e149f 100644 --- a/packages/frontend/src/scripts/array.ts +++ b/packages/frontend/src/scripts/array.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index 2a9a42ace5..9fc8f7843e 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -8,18 +8,18 @@ import getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode/'; import { popup } from '@/os.js'; -export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag'; +export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; export class Autocomplete { private suggestion: { x: Ref<number>; y: Ref<number>; - q: Ref<string | null>; + q: Ref<any>; close: () => void; } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; private currentType: string; - private textRef: Ref<string>; + private textRef: Ref<string | number | null>; private opening: boolean; private onlyType: SuggestionType[]; @@ -38,7 +38,7 @@ export class Autocomplete { /** * 対象のテキストエリアを与えてインスタンスを初期化します。 */ - constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) { + constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string | number | null>, onlyType?: SuggestionType[]) { //#region BIND this.onInput = this.onInput.bind(this); this.complete = this.complete.bind(this); @@ -49,7 +49,7 @@ export class Autocomplete { this.textarea = textarea; this.textRef = textRef; this.opening = false; - this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag']; + this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam']; this.attach(); } @@ -80,6 +80,7 @@ export class Autocomplete { const hashtagIndex = text.lastIndexOf('#'); const emojiIndex = text.lastIndexOf(':'); const mfmTagIndex = text.lastIndexOf('$'); + const mfmParamIndex = text.lastIndexOf('.'); const max = Math.max( mentionIndex, @@ -92,9 +93,12 @@ export class Autocomplete { return; } + const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop(); + const isMention = mentionIndex !== -1; const isHashtag = hashtagIndex !== -1; - const isMfmTag = mfmTagIndex !== -1; + const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam?.includes(' '); + const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); let opened = false; @@ -134,6 +138,17 @@ export class Autocomplete { } } + if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) { + const mfmParam = text.substring(mfmParamIndex + 1); + if (!mfmParam.includes(' ')) { + this.open('mfmParam', { + tag: text.substring(mfmTagIndex + 2, mfmParamIndex), + params: mfmParam.split(','), + }); + opened = true; + } + } + if (!opened) { this.close(); } @@ -142,7 +157,7 @@ export class Autocomplete { /** * サジェストを提示します。 */ - private async open(type: string, q: string | null) { + private async open(type: string, q: any) { if (type !== this.currentType) { this.close(); } @@ -280,6 +295,22 @@ export class Autocomplete { const pos = trimmedBefore.length + (value.length + 3); this.textarea.setSelectionRange(pos, pos); }); + } else if (type === 'mfmParam') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('.')); + const after = source.substring(caret); + + // 挿入 + this.text = `${trimmedBefore}.${value}${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 1); + this.textarea.setSelectionRange(pos, pos); + }); } } } diff --git a/packages/frontend/src/scripts/boost-quote.ts b/packages/frontend/src/scripts/boost-quote.ts new file mode 100644 index 0000000000..4e025f5d4f --- /dev/null +++ b/packages/frontend/src/scripts/boost-quote.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: dakkar and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only +*/ + +import { ref, Ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; +import { defaultStore } from '@/store.js'; +import { MenuItem } from '@/types/menu.js'; + +/* + this script should eventually contain all Sharkey-specific bits of + boosting and quoting that we would otherwise have to replicate in + `{M,S}kNote{,Detailed,Sub}.vue` + */ + +export type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +export function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + +export function visibilityIsAtLeast(a: Visibility | string, b: Visibility | string): boolean { + return smallerVisibility(a, b) === b; +} + +export function boostMenuItems(appearNote: Ref<Misskey.entities.Note>, renote: (v: Visibility, l: boolean) => void): MenuItem[] { + const localOnly = ref(defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly); + const effectiveVisibility = ( + appearNote.value.channel?.isSensitive + ? smallerVisibility(appearNote.value.visibility, 'home') + : appearNote.value.visibility + ); + + const menuItems: MenuItem[] = []; + if (visibilityIsAtLeast(effectiveVisibility, 'public')) { + menuItems.push({ + type: 'button', + icon: 'ph-globe-hemisphere-west ph-bold ph-lg', + text: i18n.ts._visibility['public'], + action: () => { + renote('public', localOnly.value); + }, + } as MenuItem); + } + if (visibilityIsAtLeast(effectiveVisibility, 'home')) { + menuItems.push({ + type: 'button', + icon: 'ph-house ph-bold ph-lg', + text: i18n.ts._visibility['home'], + action: () => { + renote('home', localOnly.value); + }, + } as MenuItem); + } + if (visibilityIsAtLeast(effectiveVisibility, 'followers')) { + menuItems.push({ + type: 'button', + icon: 'ph-lock ph-bold ph-lg', + text: i18n.ts._visibility['followers'], + action: () => { + renote('followers', localOnly.value); + }, + } as MenuItem); + } + + return [ + ...menuItems, + { + type: 'switch', + icon: 'ph-planet ph-bold ph-lg', + text: i18n.ts._timelines.local, + ref: localOnly, + } as MenuItem, + ]; +} diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/scripts/cache.ts index 12347cf4b1..0fbdf34d5d 100644 --- a/packages/frontend/src/scripts/cache.ts +++ b/packages/frontend/src/scripts/cache.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/chart-legend.ts b/packages/frontend/src/scripts/chart-legend.ts index e91908e0cb..2d534f60c1 100644 --- a/packages/frontend/src/scripts/chart-legend.ts +++ b/packages/frontend/src/scripts/chart-legend.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts index 336ec6cfbb..24e41245e7 100644 --- a/packages/frontend/src/scripts/chart-vline.ts +++ b/packages/frontend/src/scripts/chart-vline.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/check-animated-mfm.ts b/packages/frontend/src/scripts/check-animated-mfm.ts index 8e3ef7ee46..eac18738ee 100644 --- a/packages/frontend/src/scripts/check-animated-mfm.ts +++ b/packages/frontend/src/scripts/check-animated-mfm.ts @@ -1,4 +1,4 @@ -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean { const animatedNodes = mfm.extract(nodes, (node) => { diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts new file mode 100644 index 0000000000..e7b473dd75 --- /dev/null +++ b/packages/frontend/src/scripts/check-reaction-permissions.ts @@ -0,0 +1,12 @@ +import * as Misskey from 'misskey-js'; +import { UnicodeEmojiDef } from './emojilist.js'; + +export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean { + if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする; + if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能 + + const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? []; + return !(emoji.localOnly && note.user.host !== me.host) + && !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote')) + && (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id))); +} diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts index 5ac19c8d5b..67e896b4b9 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/chiptune2.ts b/packages/frontend/src/scripts/chiptune2.ts index e52adb07d1..5a5b1d6c24 100644 --- a/packages/frontend/src/scripts/chiptune2.ts +++ b/packages/frontend/src/scripts/chiptune2.ts @@ -150,6 +150,27 @@ ChiptuneJsPlayer.prototype.getRow = function () { return 0; }; +ChiptuneJsPlayer.prototype.getNumPatterns = function () { + if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) { + return libopenmpt._openmpt_module_get_num_patterns(this.currentPlayingNode.modulePtr); + } + return 0; +}; + +ChiptuneJsPlayer.prototype.getCurrentSpeed = function () { + if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) { + return libopenmpt._openmpt_module_get_current_speed(this.currentPlayingNode.modulePtr); + } + return 0; +}; + +ChiptuneJsPlayer.prototype.getCurrentTempo = function () { + if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) { + return libopenmpt._openmpt_module_get_current_tempo(this.currentPlayingNode.modulePtr); + } + return 0; +}; + ChiptuneJsPlayer.prototype.getPatternNumRows = function (pattern: number) { if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) { return libopenmpt._openmpt_module_get_pattern_num_rows(this.currentPlayingNode.modulePtr, pattern); @@ -164,6 +185,20 @@ ChiptuneJsPlayer.prototype.getPatternRowChannel = function (pattern: number, row return ''; }; +ChiptuneJsPlayer.prototype.getCtls = function () { + if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) { + return libopenmpt._openmpt_module_get_ctls(this.currentPlayingNode.modulePtr); + } + return 0; +}; + +ChiptuneJsPlayer.prototype.version = function () { + if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) { + return libopenmpt._openmpt_get_library_version(); + } + return 0; +}; + ChiptuneJsPlayer.prototype.createLibopenmptNode = function (buffer, config: object) { const maxFramesPerChunk = 4096; const processNode = this.audioContext.createScriptProcessor(2048, 0, 2); @@ -178,6 +213,7 @@ ChiptuneJsPlayer.prototype.createLibopenmptNode = function (buffer, config: obje processNode.paused = false; processNode.leftBufferPtr = libopenmpt._malloc(4 * maxFramesPerChunk); processNode.rightBufferPtr = libopenmpt._malloc(4 * maxFramesPerChunk); + processNode.perf = { 'current': 0, 'max': 0 }; processNode.cleanup = function () { if (this.modulePtr !== 0) { libopenmpt._openmpt_module_destroy(this.modulePtr); @@ -205,7 +241,13 @@ ChiptuneJsPlayer.prototype.createLibopenmptNode = function (buffer, config: obje processNode.togglePause = function () { this.paused = !this.paused; }; + processNode.getProcessTime = function() { + const max = this.perf.max; + this.perf.max = 0; + return { 'current': this.perf.current, 'max': max }; + }; processNode.onaudioprocess = function (e) { + let startTimeP1 = performance.now(); const outputL = e.outputBuffer.getChannelData(0); const outputR = e.outputBuffer.getChannelData(1); let framesToRender = outputL.length; @@ -231,11 +273,13 @@ ChiptuneJsPlayer.prototype.createLibopenmptNode = function (buffer, config: obje const currentPattern = libopenmpt._openmpt_module_get_current_pattern(this.modulePtr); const currentRow = libopenmpt._openmpt_module_get_current_row(this.modulePtr); + startTimeP1 = startTimeP1 - performance.now(); if (currentPattern !== this.patternIndex) { processNode.player.fireEvent('onPatternChange'); } processNode.player.fireEvent('onRowChange', { index: currentRow }); + const startTimeP2 = performance.now(); while (framesToRender > 0) { const framesPerChunk = Math.min(framesToRender, maxFramesPerChunk); const actualFramesPerChunk = libopenmpt._openmpt_module_read_float_stereo(this.modulePtr, this.context.sampleRate, framesPerChunk, this.leftBufferPtr, this.rightBufferPtr); @@ -262,6 +306,8 @@ ChiptuneJsPlayer.prototype.createLibopenmptNode = function (buffer, config: obje this.cleanup(); error ? processNode.player.fireEvent('onError', { type: 'openmpt' }) : processNode.player.fireEvent('onEnded'); } + this.perf.current = performance.now() - startTimeP2 + startTimeP1; + if (this.perf.current > this.perf.max) this.perf.max = this.perf.current; }; return processNode; }; 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/clicker-game.ts b/packages/frontend/src/scripts/clicker-game.ts index 5ad076e5ef..f9c4bc1829 100644 --- a/packages/frontend/src/scripts/clicker-game.ts +++ b/packages/frontend/src/scripts/clicker-game.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { ref, computed } from 'vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; type SaveData = { gameVersion: number; @@ -23,7 +23,7 @@ let prev = ''; export async function load() { try { - saveData.value = await os.api('i/registry/get', { + saveData.value = await misskeyApi('i/registry/get', { scope: ['clickerGame'], key: 'saveData', }); @@ -63,7 +63,7 @@ export async function save() { const current = JSON.stringify(saveData.value); if (current === prev) return; - await os.api('i/registry/set', { + await misskeyApi('i/registry/set', { scope: ['clickerGame'], key: 'saveData', value: saveData.value, diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts index 96b53684f3..ea8eea14b5 100644 --- a/packages/frontend/src/scripts/clone.ts +++ b/packages/frontend/src/scripts/clone.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -8,15 +8,15 @@ // あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった // https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045 -type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; +export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[]; export function deepClone<T extends Cloneable>(x: T): T { if (typeof x === 'object') { if (x === null) return x; if (Array.isArray(x)) return x.map(deepClone) as T; - const obj = {} as Record<string, Cloneable>; + const obj = {} as Record<string | number | symbol, Cloneable>; for (const [k, v] of Object.entries(x)) { - obj[k] = deepClone(v); + obj[k] = v === undefined ? undefined : deepClone(v); } return obj as T; } else { diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts index 957669122e..2733897bab 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/scripts/code-highlighter.ts @@ -1,10 +1,51 @@ -import { setWasm, setCDN, Highlighter, getHighlighter as _getHighlighter } from 'shiki'; - -setWasm('/assets/shiki/dist/onig.wasm'); -setCDN('/assets/shiki/'); +import { bundledThemesInfo } from 'shiki'; +import { getHighlighterCore, loadWasm } from 'shiki/core'; +import darkPlus from 'shiki/themes/dark-plus.mjs'; +import { unique } from './array.js'; +import { deepClone } from './clone.js'; +import { deepMerge } from './merge.js'; +import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki'; +import { ColdDeviceStorage } from '@/store.js'; +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; let _highlighter: Highlighter | null = null; +export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>; +export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>; +export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> { + const theme = deepClone(ColdDeviceStorage.get(mode === 'light' ? 'lightTheme' : 'darkTheme')); + + if (theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === theme.base); + if (base && base.codeHighlighter) theme.codeHighlighter = Object.assign({}, base.codeHighlighter, theme.codeHighlighter); + } + + if (theme.codeHighlighter) { + let _res: ThemeRegistration = {}; + if (theme.codeHighlighter.base === '_none_') { + _res = deepClone(theme.codeHighlighter.overrides); + } else { + const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus; + _res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base); + } + if (_res.name == null) { + _res.name = theme.id; + } + _res.type = mode; + + if (getName) { + return _res.name; + } + return _res; + } + + if (getName) { + return 'dark-plus'; + } + return darkPlus; +} + export async function getHighlighter(): Promise<Highlighter> { if (!_highlighter) { return await initHighlighter(); @@ -13,16 +54,36 @@ export async function getHighlighter(): Promise<Highlighter> { } export async function initHighlighter() { - const highlighter = await _getHighlighter({ - theme: 'dark-plus', - langs: ['js'], + const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json'); + + await loadWasm(import('shiki/onig.wasm?init')); + + // テーマの重複を消す + const themes = unique([ + darkPlus, + ...(await Promise.all([getTheme('light'), getTheme('dark')])), + ]); + + const highlighter = await getHighlighterCore({ + themes, + langs: [ + import('shiki/langs/javascript.mjs'), + aiScriptGrammar.default as unknown as LanguageRegistration, + ], + }); + + ColdDeviceStorage.watch('lightTheme', async () => { + const newTheme = await getTheme('light'); + if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + highlighter.loadTheme(newTheme); + } }); - await highlighter.loadLanguage({ - path: 'languages/aiscript.tmLanguage.json', - id: 'aiscript', - scopeName: 'source.aiscript', - aliases: ['is', 'ais'], + ColdDeviceStorage.watch('darkTheme', async () => { + const newTheme = await getTheme('dark'); + if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + highlighter.loadTheme(newTheme); + } }); _highlighter = highlighter; diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend/src/scripts/collapsed.ts index 57e6ecf5b5..237bd37c7a 100644 --- a/packages/frontend/src/scripts/collapsed.ts +++ b/packages/frontend/src/scripts/collapsed.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/scripts/collect-page-vars.ts index 79356e60eb..5096c0669e 100644 --- a/packages/frontend/src/scripts/collect-page-vars.ts +++ b/packages/frontend/src/scripts/collect-page-vars.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/color.ts b/packages/frontend/src/scripts/color.ts index 25ef41d9b7..a11255ffd1 100644 --- a/packages/frontend/src/scripts/color.ts +++ b/packages/frontend/src/scripts/color.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/confetti.ts b/packages/frontend/src/scripts/confetti.ts index b394ba3e2a..8e53a6ceeb 100644 --- a/packages/frontend/src/scripts/confetti.ts +++ b/packages/frontend/src/scripts/confetti.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/scripts/contains.ts index b50ce4128c..6137c06e85 100644 --- a/packages/frontend/src/scripts/contains.ts +++ b/packages/frontend/src/scripts/contains.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts index 3884d4a20a..216c0464b3 100644 --- a/packages/frontend/src/scripts/copy-to-clipboard.ts +++ b/packages/frontend/src/scripts/copy-to-clipboard.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts index 3843052a24..7c33f8ccee 100644 --- a/packages/frontend/src/scripts/device-kind.ts +++ b/packages/frontend/src/scripts/device-kind.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,6 +11,13 @@ const ua = navigator.userAgent.toLowerCase(); const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700); const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua); +const isIPhone = /iphone|ipod/gi.test(ua) && navigator.maxTouchPoints > 1; +// navigator.platform may be deprecated but this check is still required +const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; +const isIos = /ipad|iphone|ipod/gi.test(ua) && navigator.maxTouchPoints > 1; + +export const isFullscreenNotSupported = isIPhone || isIos; + export const deviceKind: 'smartphone' | 'tablet' | 'desktop' = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind : isSmartphone ? 'smartphone' : isTablet ? 'tablet' diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend/src/scripts/emoji-base.ts index 46a13462a1..16a5a6aa5b 100644 --- a/packages/frontend/src/scripts/emoji-base.ts +++ b/packages/frontend/src/scripts/emoji-base.ts @@ -1,10 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ const twemojiSvgBase = '/twemoji'; const fluentEmojiPngBase = '/fluent-emoji'; +const tossfaceSvgBase = '/tossface'; export function char2twemojiFilePath(char: string): string { let codes = Array.from(char, x => x.codePointAt(0)?.toString(16)); @@ -23,3 +24,14 @@ export function char2fluentEmojiFilePath(char: string): string { const fileName = codes.map(x => x!.padStart(4, '0')).join('-'); return `${fluentEmojiPngBase}/${fileName}.png`; } + +export function char2tossfaceFilePath(char: string): string { + let codes = Array.from(char, x => x.codePointAt(0)?.toString(16)); + // Twemoji is the only emoji font which still supports the shibuya 50 emoji to this day + if (codes[0]?.startsWith('e50a')) return char2twemojiFilePath(char); + // Tossface does not use the fe0f modifier + codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.join('-'); + return `${tossfaceSvgBase}/${fileName}.svg`; +} diff --git a/packages/frontend/src/scripts/emoji-picker.ts b/packages/frontend/src/scripts/emoji-picker.ts index f87c3f6fb2..14b5cbf35e 100644 --- a/packages/frontend/src/scripts/emoji-picker.ts +++ b/packages/frontend/src/scripts/emoji-picker.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts index 8885bf4b7f..6565feba97 100644 --- a/packages/frontend/src/scripts/emojilist.ts +++ b/packages/frontend/src/scripts/emojilist.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -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,15 +39,33 @@ for (let i = 0; i < emojilist.length; i++) { export const emojiCharByCategory = _charGroupByCategory; -export function getEmojiName(char: string): string | null { - const idx = _indexByChar.get(char); - if (idx == null) { - return null; +export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string { + // Colorize it because emojilist.json assumes that + return unicodeEmojisMap.get(colorizeEmoji(char)) + // カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする + ?? unicodeEmojisMap.get(char) + // それでも見つからない場合はそのまま返す(絵文字情報がjsonに無い場合、このフォールバックが無いとレンダリングに失敗する) + ?? char; +} + +export function getEmojiName(char: string): string { + // Colorize it because emojilist.json assumes that + const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char); + if (idx === undefined) { + // 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い + return char; } else { return emojilist[idx].name; } } +/** + * テキストスタイル絵文字(U+260Eなどの1文字で表現される絵文字)をカラースタイル絵文字に変換します(VS16:U+FE0Fを付与)。 + */ +export function colorizeEmoji(char: string) { + return char.length === 1 ? `${char}\uFE0F` : char; +} + export interface CustomEmojiFolderTree { value: string; category: string; diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts index 57b296ab2a..992f6e9a16 100644 --- a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts +++ b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/scripts/extract-mentions.ts index fe60f9a851..89a5ce1df8 100644 --- a/packages/frontend/src/scripts/extract-mentions.ts +++ b/packages/frontend/src/scripts/extract-mentions.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ // test is located in test/extract-mentions -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/scripts/extract-url-from-mfm.ts index d2fbfbcc00..a4c84aa740 100644 --- a/packages/frontend/src/scripts/extract-url-from-mfm.ts +++ b/packages/frontend/src/scripts/extract-url-from-mfm.ts @@ -1,9 +1,9 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from '@sharkey/sfm-js'; +import * as mfm from '@transfem-org/sfm-js'; import { unique } from '@/scripts/array.js'; // unique without hash diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts index 6a31ebd431..ea6ee61c88 100644 --- a/packages/frontend/src/scripts/focus.ts +++ b/packages/frontend/src/scripts/focus.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts index 222fd9b0b7..b0db404f28 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/scripts/form.ts @@ -1,36 +1,48 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -type EnumItem = string | {label: string; value: string;}; +type EnumItem = string | { + label: string; + value: string; +}; + 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; @@ -38,14 +50,28 @@ export type FormItem = { }[]; } | { label?: string; + type: 'range'; + default: number | null; + description?: string; + required?: boolean; + step?: number; + min: number; + max: number; + textConverter?: (value: number) => string; +} | { + label?: string; type: 'object'; default: Record<string, unknown> | null; - hidden: true; + hidden: boolean; } | { label?: string; type: 'array'; default: unknown[] | null; - hidden: true; + hidden: boolean; +} | { + type: 'button'; + content?: string; + action: (ev: MouseEvent, v: any) => void; }; export type Form = Record<string, FormItem>; @@ -55,6 +81,7 @@ type GetItemType<Item extends FormItem> = Item['type'] extends 'number' ? number : Item['type'] extends 'boolean' ? boolean : Item['type'] extends 'radio' ? unknown : + Item['type'] extends 'range' ? number : Item['type'] extends 'enum' ? string : Item['type'] extends 'array' ? unknown[] : Item['type'] extends 'object' ? Record<string, unknown> diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/scripts/format-time-string.ts index 918996dd10..35ad77d982 100644 --- a/packages/frontend/src/scripts/format-time-string.ts +++ b/packages/frontend/src/scripts/format-time-string.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts index 54654980f2..60884d08d3 100644 --- a/packages/frontend/src/scripts/gen-search-query.ts +++ b/packages/frontend/src/scripts/gen-search-query.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -18,7 +18,7 @@ export async function genSearchQuery(v: any, q: string) { host = at; } } else { - const user = await v.os.api('users/show', Misskey.acct.parse(at)).catch(x => null); + const user = await v.api('users/show', Misskey.acct.parse(at)).catch(x => null); if (user) { userId = user.id; } else { diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/scripts/get-account-from-id.ts index 346d283572..40afa10f2d 100644 --- a/packages/frontend/src/scripts/get-account-from-id.ts +++ b/packages/frontend/src/scripts/get-account-from-id.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts index d6a5b00c0b..a883404307 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/scripts/get-drive-file-menu.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -8,6 +8,7 @@ import { defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { MenuItem } from '@/types/menu.js'; import { defaultStore } from '@/store.js'; @@ -18,7 +19,7 @@ function rename(file: Misskey.entities.DriveFile) { default: file.name, }).then(({ canceled, result: name }) => { if (canceled) return; - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, name: name, }); @@ -31,7 +32,7 @@ function describe(file: Misskey.entities.DriveFile) { file: file, }, { done: caption => { - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, comment: caption.length === 0 ? null : caption, }); @@ -40,7 +41,7 @@ function describe(file: Misskey.entities.DriveFile) { } function toggleSensitive(file: Misskey.entities.DriveFile) { - os.api('drive/files/update', { + misskeyApi('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, }).catch(err => { @@ -65,11 +66,11 @@ function addApp() { async function deleteFile(file: Misskey.entities.DriveFile) { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('driveFileDeleteConfirm', { name: file.name }), + text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); if (canceled) return; - os.api('drive/files/delete', { + misskeyApi('drive/files/delete', { fileId: file.id, }); } @@ -103,7 +104,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss }), }] : [], { type: 'divider' }, { text: i18n.ts.createNoteFromTheFile, - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', action: () => os.post({ initialFiles: [file], }), diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index a409f1b775..40359a88bb 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -1,15 +1,16 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, Ref } from 'vue'; +import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { claimAchievement } from './achievements.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; import { defaultStore, noteActions } from '@/store.js'; @@ -35,18 +36,18 @@ export async function getNoteClipMenu(props: { const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; const clips = await clipsCache.fetch(); - return [...clips.map(clip => ({ + const menu: MenuItem[] = [...clips.map(clip => ({ text: clip.name, action: () => { claimAchievement('noteClipped1'); os.promiseDialog( - os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + misskeyApi('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), null, async (err) => { if (err.id === '734806c4-542c-463a-9311-15c512803965') { const confirm = await os.confirm({ type: 'warning', - text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }), }); if (!confirm.canceled) { os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); @@ -92,6 +93,8 @@ export async function getNoteClipMenu(props: { os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); }, }]; + + return menu; } export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem { @@ -99,10 +102,13 @@ export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): Men icon: 'ph-warning-circle ph-bold ph-lg', text, action: (): void => { - const u = note.url ?? note.uri ?? `${url}/notes/${note.id}`; + const localUrl = `${url}/notes/${note.id}`; + let noteInfo = ''; + if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`; + noteInfo += `Local Note: ${localUrl}\n`; os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { user: note.user, - initialComment: `Note: ${u}\n-----\n`, + initialComment: `${noteInfo}-----\n`, }, {}, 'closed'); }, }; @@ -132,7 +138,6 @@ export function getCopyNoteOriginLinkMenu(note: misskey.entities.Note, text: str export function getNoteMenu(props: { note: Misskey.entities.Note; - menuButton: Ref<HTMLElement>; translation: Ref<Misskey.entities.NotesTranslateResponse | null>; translating: Ref<boolean>; isDeleted: Ref<boolean>; @@ -156,7 +161,7 @@ export function getNoteMenu(props: { }).then(({ canceled }) => { if (canceled) return; - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: appearNote.id, }); @@ -173,7 +178,7 @@ export function getNoteMenu(props: { }).then(({ canceled }) => { if (canceled) return; - os.api('notes/delete', { + misskeyApi('notes/delete', { noteId: appearNote.id, }); @@ -252,7 +257,7 @@ export function getNoteMenu(props: { function share(): void { navigator.share({ - title: i18n.t('noteOf', { user: appearNote.user.name }), + title: i18n.tsx.noteOf({ user: appearNote.user.name }), text: appearNote.text, url: `${url}/notes/${appearNote.id}`, }); @@ -265,7 +270,7 @@ export function getNoteMenu(props: { async function translate(): Promise<void> { if (props.translation.value != null) return; props.translating.value = true; - const res = await os.api('notes/translate', { + const res = await misskeyApi('notes/translate', { noteId: appearNote.id, targetLang: miLocalStorage.getItem('lang') ?? navigator.language, }); @@ -275,7 +280,7 @@ export function getNoteMenu(props: { let menu: MenuItem[]; if ($i) { - const statePromise = os.api('notes/state', { + const statePromise = misskeyApi('notes/state', { noteId: appearNote.id, }); @@ -296,7 +301,7 @@ export function getNoteMenu(props: { text: i18n.ts.copyContent, action: copyContent, }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink) - , (appearNote.url || appearNote.uri) ? + , (appearNote.url || appearNote.uri) ? getCopyNoteOriginLinkMenu(appearNote, 'Copy link (Origin)') : undefined, (appearNote.url || appearNote.uri) ? { @@ -355,7 +360,7 @@ export function getNoteMenu(props: { icon: 'ph-user ph-bold ph-lg', text: i18n.ts.user, children: async () => { - const user = appearNote.userId === $i?.id ? $i : await os.api('users/show', { userId: appearNote.userId }); + const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId }); const { menu, cleanup } = getUserMenu(user); cleanups.push(cleanup); return menu; @@ -377,19 +382,55 @@ export function getNoteMenu(props: { ] : [] ), + ...(appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin) ? [ + { type: 'divider' }, + { + type: 'parent' as const, + icon: 'ti ti-device-tv', + text: i18n.ts.channel, + children: async () => { + const channelChildMenu = [] as MenuItem[]; + + const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id }); + + if (channel.pinnedNoteIds.includes(appearNote.id)) { + channelChildMenu.push({ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => os.apiWithDialog('channels/update', { + channelId: appearNote.channel!.id, + pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id), + }), + }); + } else { + channelChildMenu.push({ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => os.apiWithDialog('channels/update', { + channelId: appearNote.channel!.id, + pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id], + }), + }); + } + return channelChildMenu; + }, + }, + ] + : [] + ), ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ { type: 'divider' }, appearNote.userId === $i.id ? { - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.edit, action: edit, } : undefined, { - icon: 'ph-pencil-line ph-bold ph-lg', + icon: 'ph-pencil-simple-line ph-bold ph-lg', text: i18n.ts.deleteAndEdit, danger: true, action: delEdit, - }, + }, { icon: 'ph-trash ph-bold ph-lg', text: i18n.ts.delete, @@ -409,7 +450,7 @@ export function getNoteMenu(props: { text: i18n.ts.copyContent, action: copyContent, }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink) - , (appearNote.url || appearNote.uri) ? + , (appearNote.url || appearNote.uri) ? getCopyNoteOriginLinkMenu(appearNote, 'Copy link (Origin)') : undefined, (appearNote.url || appearNote.uri) ? { @@ -468,7 +509,7 @@ function smallerVisibility(a: Visibility | string, b: Visibility | string): Visi export function getRenoteMenu(props: { note: Misskey.entities.Note; - renoteButton: Ref<HTMLElement>; + renoteButton: ShallowRef<HTMLElement | undefined>; mock?: boolean; }) { const isRenote = ( @@ -488,7 +529,7 @@ export function getRenoteMenu(props: { text: i18n.ts.inChannelRenote, icon: 'ti ti-repeat', action: () => { - const el = props.renoteButton.value as HTMLElement | null | undefined; + const el = props.renoteButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -497,7 +538,7 @@ export function getRenoteMenu(props: { } if (!props.mock) { - os.api('notes/create', { + misskeyApi('notes/create', { renoteId: appearNote.id, channelId: appearNote.channelId, }).then(() => { @@ -524,7 +565,7 @@ export function getRenoteMenu(props: { text: i18n.ts.renote, icon: 'ti ti-repeat', action: () => { - const el = props.renoteButton.value as HTMLElement | null | undefined; + const el = props.renoteButton.value; if (el) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); @@ -542,7 +583,7 @@ export function getRenoteMenu(props: { } if (!props.mock) { - os.api('notes/create', { + misskeyApi('notes/create', { localOnly, visibility, renoteId: appearNote.id, @@ -564,7 +605,7 @@ export function getRenoteMenu(props: { const renoteItems = [ ...normalRenoteItems, - ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] : [], + ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [], ...channelRenoteItems, ]; diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts index 1fd9f04d46..6fd9947ac1 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,7 +10,11 @@ import { i18n } from '@/i18n.js'; * 投稿を表す文字列を取得します。 * @param {*} note (packされた)投稿 */ -export const getNoteSummary = (note: Misskey.entities.Note): string => { +export const getNoteSummary = (note?: Misskey.entities.Note | null): string => { + if (note == null) { + return ''; + } + if (note.deletedAt) { return `(${i18n.ts.deletedNote})`; } @@ -30,7 +34,7 @@ export const getNoteSummary = (note: Misskey.entities.Note): string => { // ファイルが添付されているとき if ((note.files || []).length !== 0) { - summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`; + summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`; } // 投票が添付されているとき diff --git a/packages/frontend/src/scripts/get-note-versions-menu.ts b/packages/frontend/src/scripts/get-note-versions-menu.ts index 46e3bab3a7..84292f1277 100644 --- a/packages/frontend/src/scripts/get-note-versions-menu.ts +++ b/packages/frontend/src/scripts/get-note-versions-menu.ts @@ -2,6 +2,7 @@ import { Ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from './misskey-api.js'; import { MenuItem } from '@/types/menu.js'; import { dateTimeFormat } from './intl-const.js'; @@ -30,7 +31,7 @@ export async function getNoteVersionsMenu(props: { } const menu: MenuItem[] = []; - const statePromise = os.api('notes/versions', { + const statePromise = misskeyApi('notes/versions', { noteId: appearNote.id, }); @@ -39,9 +40,9 @@ export async function getNoteVersionsMenu(props: { const _time = edit.oldDate == null ? NaN : typeof edit.oldDate === 'number' ? edit.oldDate : (edit.oldDate instanceof Date ? edit.oldDate : new Date(edit.oldDate)).getTime(); - + menu.push({ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: _time ? dateTimeFormat.format(_time) : dateTimeFormat.format(new Date(edit.updatedAt)), action: () => openVersion(edit), }); diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 67bc781aef..61f9a453dc 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,13 +10,14 @@ import { i18n } from '@/i18n.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { host, url } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore, userActions } from '@/store.js'; import { $i, iAmModerator } from '@/account.js'; -import { mainRouter } from '@/router.js'; -import { Router } from '@/nirax.js'; +import { IRouter } from '@/nirax.js'; import { antennasCache, rolesCache, userListsCache } from '@/cache.js'; +import { mainRouter } from '@/router/main.js'; -export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) { +export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { const meId = $i ? $i.id : null; const cleanups = [] as (() => void)[]; @@ -131,7 +132,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router } async function editMemo(): Promise<void> { - const userDetailed = await os.api('users/show', { + const userDetailed = await misskeyApi('users/show', { userId: user.id, }); const { canceled, result } = await os.form(i18n.ts.editMemo, { @@ -169,20 +170,21 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router action: () => { copyToClipboard(`${user.host ?? host}/@${user.username}.atom`); }, - }, { + }, ...(user.host != null && user.url != null ? [{ + icon: 'ph-share ph-bold ph-lg', + text: i18n.ts.showOnRemote, + action: () => { + if (user.url == null) return; + window.open(user.url, '_blank', 'noopener'); + }, + }] : []), { icon: 'ph-share-network ph-bold ph-lg', text: i18n.ts.copyProfileUrl, action: () => { const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; copyToClipboard(`${url}/${canonical}`); }, - }, ...(user.host ? [{ - icon: 'ph-share ph-bold ph-lg', - text: i18n.ts.openRemoteProfile, - action: () => { - open(`${user.uri}`, '_blank'); - }, - }] : []), { + }, { icon: 'ph-envelope ph-bold ph-lg', text: i18n.ts.sendMessage, action: () => { @@ -190,7 +192,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router os.post({ specified: user, initialText: `${canonical} ` }); }, }, { type: 'divider' }, { - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.editMemo, action: () => { editMemo(); @@ -362,7 +364,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router if ($i && meId === user.id) { menu = menu.concat([{ type: 'divider' }, { - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.editProfile, action: () => { router.push('/settings/profile'); diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts index 3ae80d7fc3..56e91abba0 100644 --- a/packages/frontend/src/scripts/get-user-name.ts +++ b/packages/frontend/src/scripts/get-user-name.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts index 48c80c066b..0600bff893 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/scripts/hotkey.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts index 8e5f17f38a..c2f44a33cc 100644 --- a/packages/frontend/src/scripts/i18n.ts +++ b/packages/frontend/src/scripts/i18n.ts @@ -1,34 +1,294 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import type { ILocale, ParameterizedString } from '../../../../locales/index.js'; -export class I18n<T extends Record<string, any>> { - public ts: T; +type FlattenKeys<T extends ILocale, TPrediction> = keyof { + [K in keyof T as T[K] extends ILocale + ? FlattenKeys<T[K], TPrediction> extends infer C extends string + ? `${K & string}.${C}` + : never + : T[K] extends TPrediction + ? K + : never]: T[K]; +}; - constructor(locale: T) { - this.ts = locale; +type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedString>> = TKey extends `${infer K}.${infer C}` + // @ts-expect-error -- C は明らかに FlattenKeys<T[K], ParameterizedString> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。 + ? ParametersOf<T[K], C> + : TKey extends keyof T + ? T[TKey] extends ParameterizedString<infer P> + ? P + : never + : never; +type Tsx<T extends ILocale> = { + readonly [K in keyof T as T[K] extends string ? never : K]: T[K] extends ParameterizedString<infer P> + ? (arg: { readonly [_ in P]: string | number }) => string + // @ts-expect-error -- 証明省略 + : Tsx<T[K]>; +}; + +export class I18n<T extends ILocale> { + private tsxCache?: Tsx<T>; + + constructor(public locale: T) { //#region BIND this.t = this.t.bind(this); //#endregion } - // string にしているのは、ドット区切りでのパス指定を許可するため - // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも - public t(key: string, args?: Record<string, string | number>): string { - try { - let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + public get ts(): T { + if (_DEV_) { + class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> { + get(target: TTarget, p: string | symbol): unknown { + const value = target[p as keyof TTarget]; + + if (typeof value === 'object') { + return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>()); + } + + if (typeof value === 'string') { + const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter); + + if (parameters.length) { + console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`); + } + + return value; + } + + console.error(`Unexpected locale key: ${String(p)}`); + + return p; + } + } + + return new Proxy(this.locale, new Handler()); + } + + return this.locale; + } + + public get tsx(): Tsx<T> { + if (_DEV_) { + if (this.tsxCache) { + return this.tsxCache; + } + + class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> { + get(target: TTarget, p: string | symbol): unknown { + const value = target[p as keyof TTarget]; + + if (typeof value === 'object') { + return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>()); + } + + if (typeof value === 'string') { + const quasis: string[] = []; + const expressions: string[] = []; + let cursor = 0; + + while (~cursor) { + const start = value.indexOf('{', cursor); + + if (!~start) { + quasis.push(value.slice(cursor)); + break; + } + + quasis.push(value.slice(cursor, start)); + + const end = value.indexOf('}', start); + + expressions.push(value.slice(start + 1, end)); + + cursor = end + 1; + } + + if (!expressions.length) { + console.error(`Unexpected locale key: ${String(p)}`); + + return () => value; + } + + return (arg) => { + let str = quasis[0]; + + for (let i = 0; i < expressions.length; i++) { + if (!Object.hasOwn(arg, expressions[i])) { + console.error(`Missing locale parameters: ${expressions[i]} at ${String(p)}`); + } + + str += arg[expressions[i]] + quasis[i + 1]; + } + + return str; + }; + } + + console.error(`Unexpected locale key: ${String(p)}`); + + return p; + } + } + + return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.tsxCache) { + return this.tsxCache; + } + + function build(target: ILocale): Tsx<T> { + const result = {} as Tsx<T>; + + for (const k in target) { + if (!Object.hasOwn(target, k)) { + continue; + } + + const value = target[k as keyof typeof target]; + + if (typeof value === 'object') { + result[k] = build(value as ILocale); + } else if (typeof value === 'string') { + const quasis: string[] = []; + const expressions: string[] = []; + let cursor = 0; + + while (~cursor) { + const start = value.indexOf('{', cursor); + + if (!~start) { + quasis.push(value.slice(cursor)); + break; + } + + quasis.push(value.slice(cursor, start)); + + const end = value.indexOf('}', start); + + expressions.push(value.slice(start + 1, end)); + + cursor = end + 1; + } + + if (!expressions.length) { + continue; + } + + result[k] = (arg) => { + let str = quasis[0]; + + for (let i = 0; i < expressions.length; i++) { + str += arg[expressions[i]] + quasis[i + 1]; + } + + return str; + }; + } + } + return result; + } + + return this.tsxCache = build(this.locale); + } + + /** + * @deprecated なるべくこのメソッド使うよりも ts 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも + */ + public t<TKey extends FlattenKeys<T, string>>(key: TKey): string; + /** + * @deprecated なるべくこのメソッド使うよりも tsx 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも + */ + public t<TKey extends FlattenKeys<T, ParameterizedString>>(key: TKey, args: { readonly [_ in ParametersOf<T, TKey>]: string | number }): string; + public t(key: string, args?: { readonly [_: string]: string | number }) { + let str: string | ParameterizedString | ILocale = this.locale; + + for (const k of key.split('.')) { + str = str[k]; - if (args) { - for (const [k, v] of Object.entries(args)) { - str = str.replace(`{${k}}`, v.toString()); + if (_DEV_) { + if (typeof str === 'undefined') { + console.error(`Unexpected locale key: ${key}`); + return key; } } - return str; - } catch (err) { - console.warn(`missing localization '${key}'`); - return key; } + + if (args) { + if (_DEV_) { + const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter)); + + if (missing.length) { + console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`); + } + } + + for (const [k, v] of Object.entries(args)) { + const search = `{${k}}`; + + if (_DEV_) { + if (!(str as string).includes(search)) { + console.error(`Unexpected locale parameter: ${k} at ${key}`); + } + } + + str = (str as string).replace(search, v.toString()); + } + } + + return str; } } + +if (import.meta.vitest) { + const { describe, expect, it } = import.meta.vitest; + + describe('i18n', () => { + it('t', () => { + const i18n = new I18n({ + foo: 'foo', + bar: { + baz: 'baz', + qux: 'qux {0}' as unknown as ParameterizedString<'0'>, + quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, + }, + }); + + expect(i18n.t('foo')).toBe('foo'); + expect(i18n.t('bar.baz')).toBe('baz'); + expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); + expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); + }); + it('ts', () => { + const i18n = new I18n({ + foo: 'foo', + bar: { + baz: 'baz', + qux: 'qux {0}' as unknown as ParameterizedString<'0'>, + quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, + }, + }); + + expect(i18n.ts.foo).toBe('foo'); + expect(i18n.ts.bar.baz).toBe('baz'); + }); + it('tsx', () => { + const i18n = new I18n({ + foo: 'foo', + bar: { + baz: 'baz', + qux: 'qux {0}' as unknown as ParameterizedString<'0'>, + quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, + }, + }); + + expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); + expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); + }); + }); +} diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts index a20cfcb1d0..1ca0990ba9 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts index ac1be50c73..6adfedcb9f 100644 --- a/packages/frontend/src/scripts/idle-render.ts +++ b/packages/frontend/src/scripts/idle-render.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts index ebf27667d7..2465a14703 100644 --- a/packages/frontend/src/scripts/init-chart.ts +++ b/packages/frontend/src/scripts/init-chart.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts index 007fc0f2f7..1517e4e1e8 100644 --- a/packages/frontend/src/scripts/initialize-sw.ts +++ b/packages/frontend/src/scripts/initialize-sw.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/install-plugin.ts b/packages/frontend/src/scripts/install-plugin.ts index 1310a0dc73..15b0cedc79 100644 --- a/packages/frontend/src/scripts/install-plugin.ts +++ b/packages/frontend/src/scripts/install-plugin.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,6 +10,7 @@ import { Interpreter, Parser, utils } from '@syuilo/aiscript'; import type { Plugin } from '@/store.js'; import { ColdDeviceStorage } from '@/store.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; export type AiScriptPluginMeta = { @@ -63,7 +64,11 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> try { ast = parser.parse(code); } catch (err) { - throw new Error('Aiscript syntax error'); + if (err instanceof Error) { + throw new Error(`Aiscript syntax error\n${(err as Error).message}`); + } else { + throw new Error('Aiscript syntax error'); + } } const meta = Interpreter.collectMetadata(ast); @@ -110,7 +115,7 @@ export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { }, { done: async result => { const { name, permissions } = result; - const { token } = await os.api('miauth/gen-token', { + const { token } = await misskeyApi('miauth/gen-token', { session: null, name: name, permission: permissions, diff --git a/packages/frontend/src/scripts/install-theme.ts b/packages/frontend/src/scripts/install-theme.ts index 394b642bf4..866f1225bf 100644 --- a/packages/frontend/src/scripts/install-theme.ts +++ b/packages/frontend/src/scripts/install-theme.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/intl-const.ts b/packages/frontend/src/scripts/intl-const.ts index ea16c9c2ae..aaa4f0a86e 100644 --- a/packages/frontend/src/scripts/intl-const.ts +++ b/packages/frontend/src/scripts/intl-const.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -33,6 +33,10 @@ try { } export const dateTimeFormat = _dateTimeFormat; +export const timeZone = dateTimeFormat.resolvedOptions().timeZone; + +export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N'; + let _numberFormat: Intl.NumberFormat; try { _numberFormat = new Intl.NumberFormat(versatileLang); diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts index badc295726..4f487c7cb9 100644 --- a/packages/frontend/src/scripts/is-device-darkmode.ts +++ b/packages/frontend/src/scripts/is-device-darkmode.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts index dc0e90d20a..406404c462 100644 --- a/packages/frontend/src/scripts/isFfVisibleForMe.ts +++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts index 57bc4d19ba..bc1f485f5e 100644 --- a/packages/frontend/src/scripts/keycode.ts +++ b/packages/frontend/src/scripts/keycode.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts index 3912d58d82..b32de15963 100644 --- a/packages/frontend/src/scripts/langmap.ts +++ b/packages/frontend/src/scripts/langmap.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts index fe0e17e66e..b52735caa0 100644 --- a/packages/frontend/src/scripts/login-id.ts +++ b/packages/frontend/src/scripts/login-id.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/lookup-user.ts index a35fe898e4..efc9132e75 100644 --- a/packages/frontend/src/scripts/lookup-user.ts +++ b/packages/frontend/src/scripts/lookup-user.ts @@ -1,11 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; export async function lookupUser() { const { canceled, result } = await os.inputText({ @@ -17,8 +18,8 @@ export async function lookupUser() { os.pageWindow(`/admin/user/${user.id}`); }; - const usernamePromise = os.api('users/show', Misskey.acct.parse(result)); - const idPromise = os.api('users/show', { userId: result }); + const usernamePromise = misskeyApi('users/show', Misskey.acct.parse(result)); + const idPromise = misskeyApi('users/show', { userId: result }); let _notFound = false; const notFound = () => { if (_notFound) { diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts index 979f40f038..7f020b15cc 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/scripts/lookup.ts @@ -1,12 +1,13 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { Router } from '@/nirax.js'; +import { mainRouter } from '@/router/main.js'; export async function lookup(router?: Router) { const _router = router ?? mainRouter; @@ -28,7 +29,7 @@ export async function lookup(router?: Router) { } if (query.startsWith('https://')) { - const promise = os.api('ap/show', { + const promise = misskeyApi('ap/show', { uri: query, }); diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 559e61211d..099a22163a 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts new file mode 100644 index 0000000000..4e39a0fa06 --- /dev/null +++ b/packages/frontend/src/scripts/merge.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { deepClone } from './clone.js'; +import type { Cloneable } from './clone.js'; + +type DeepPartial<T> = { + [P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P]; +}; + +function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * valueにないキーをdefからもらう(再帰的)\ + * nullはそのまま、undefinedはdefの値 + **/ +export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: DeepPartial<X>, def: X): X { + if (isPureObject(value) && isPureObject(def)) { + const result = deepClone(value as Cloneable) as X; + for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { + if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) { + result[k] = v; + } else if (isPureObject(v) && isPureObject(result[k])) { + const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<string | number | symbol, unknown>>; + result[k] = deepMerge<typeof v>(child, v); + } + } + return result; + } + throw new Error('deepMerge: value and def must be pure objects'); +} diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts index 6e25cc856c..36de146c27 100644 --- a/packages/frontend/src/scripts/mfm-function-picker.ts +++ b/packages/frontend/src/scripts/mfm-function-picker.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/misskey-api.ts index 8f3a163938..49fb6f9e59 100644 --- a/packages/frontend/src/scripts/api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,12 +10,17 @@ import { $i } from '@/account.js'; export const pendingApiRequestsCount = ref(0); // Implements Misskey.api.ApiClient.request -export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>( +export function misskeyApi< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, +>( endpoint: E, data: P = {} as any, token?: string | null | undefined, signal?: AbortSignal, -): Promise<Misskey.api.SwitchCaseResponseType<E, P>> { +): Promise<_ResT> { if (endpoint.includes('://')) throw new Error('invalid endpoint'); pendingApiRequestsCount.value++; @@ -23,7 +28,7 @@ export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoin pendingApiRequestsCount.value--; }; - const promise = new Promise<Misskey.Endpoints[E]['res'] | void>((resolve, reject) => { + const promise = new Promise<_ResT>((resolve, reject) => { // Append a credential if ($i) (data as any).i = $i.token; if (token !== undefined) (data as any).i = token; @@ -44,7 +49,7 @@ export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoin if (res.status === 200) { resolve(body); } else if (res.status === 204) { - resolve(); + resolve(undefined as _ResT); // void -> undefined } else { reject(body.error); } @@ -57,10 +62,15 @@ export function api<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoin } // Implements Misskey.api.ApiClient.request -export function apiGet<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req']>( +export function misskeyApiGet< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, +>( endpoint: E, data: P = {} as any, -): Promise<Misskey.api.SwitchCaseResponseType<E, P>> { +): Promise<_ResT> { pendingApiRequestsCount.value++; const onFinally = () => { @@ -69,7 +79,7 @@ export function apiGet<E extends keyof Misskey.Endpoints, P extends Misskey.Endp const query = new URLSearchParams(data as any); - const promise = new Promise<Misskey.Endpoints[E]['res'] | void>((resolve, reject) => { + const promise = new Promise<_ResT>((resolve, reject) => { // Send request window.fetch(`${apiUrl}/${endpoint}?${query}`, { method: 'GET', @@ -81,7 +91,7 @@ export function apiGet<E extends keyof Misskey.Endpoints, P extends Misskey.Endp if (res.status === 200) { resolve(body); } else if (res.status === 204) { - resolve(); + resolve(undefined as _ResT); // void -> undefined } else { reject(body.error); } diff --git a/packages/frontend/src/scripts/navigator.ts b/packages/frontend/src/scripts/navigator.ts index b13186a10e..ffc0a457f4 100644 --- a/packages/frontend/src/scripts/navigator.ts +++ b/packages/frontend/src/scripts/navigator.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/nyaize.ts b/packages/frontend/src/scripts/nyaize.ts index 62833b4de3..58ed88fed1 100644 --- a/packages/frontend/src/scripts/nyaize.ts +++ b/packages/frontend/src/scripts/nyaize.ts @@ -1,23 +1,28 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -const enRegex1 = /(?<=n)a/gi; -const enRegex2 = /(?<=morn)ing/gi; -const enRegex3 = /(?<=every)one/gi; const koRegex1 = /[나-낳]/g; const koRegex2 = /(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm; const koRegex3 = /(야(?=\?))|(야$)|(야(?= ))/gm; +function ifAfter(prefix, fn) { + const preLen = prefix.length; + const regex = new RegExp(prefix,'i'); + return (x,pos,string) => { + return pos > 0 && string.substring(pos-preLen,pos).match(regex) ? fn(x) : x; + }; +} + export function nyaize(text: string): string { return text // ja-JP .replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ') // en-US - .replace(enRegex1, x => x === 'A' ? 'YA' : 'ya') - .replace(enRegex2, x => x === 'ING' ? 'YAN' : 'yan') - .replace(enRegex3, x => x === 'ONE' ? 'NYAN' : 'nyan') + .replace(/a/gi, ifAfter('n', x => x === 'A' ? 'YA' : 'ya')) + .replace(/ing/gi, ifAfter('morn', x => x === 'ING' ? 'YAN' : 'yan')) + .replace(/one/gi, ifAfter('every', x => x === 'ONE' ? 'NYAN' : 'nyan')) // ko-KR .replace(koRegex1, match => String.fromCharCode( match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0), diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts index 369e46aae1..0e3b093ecf 100644 --- a/packages/frontend/src/scripts/page-metadata.ts +++ b/packages/frontend/src/scripts/page-metadata.ts @@ -1,13 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import * as Misskey from 'misskey-js'; -import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue'; - -export const setPageMetadata = Symbol('setPageMetadata'); -export const pageMetadataProvider = Symbol('pageMetadataProvider'); +import { MaybeRefOrGetter, Ref, inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue'; export type PageMetadata = { title: string; @@ -18,29 +15,56 @@ export type PageMetadata = { needWideArea?: boolean; }; -export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void { - const _metadata = isRef(metadata) ? metadata : ref(metadata); +type PageMetadataGetter = () => PageMetadata; +type PageMetadataReceiver = (getter: PageMetadataGetter) => void; - provide(pageMetadataProvider, _metadata); +const RECEIVER_KEY = Symbol('ReceiverKey'); +const setReceiver = (v: PageMetadataReceiver): void => { + provide<PageMetadataReceiver>(RECEIVER_KEY, v); +}; +const getReceiver = (): PageMetadataReceiver | undefined => { + return inject<PageMetadataReceiver>(RECEIVER_KEY); +}; - const set = inject(setPageMetadata) as any; - if (set) { - set(_metadata); +const METADATA_KEY = Symbol('MetadataKey'); +const setMetadata = (v: Ref<PageMetadata | null>): void => { + provide<Ref<PageMetadata | null>>(METADATA_KEY, v); +}; +const getMetadata = (): Ref<PageMetadata | null> | undefined => { + return inject<Ref<PageMetadata | null>>(METADATA_KEY); +}; - onMounted(() => { - set(_metadata); - }); +export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => { + const metadataRef = ref(toValue(maybeRefOrGetterMetadata)); + const metadataGetter = () => metadataRef.value; + const receiver = getReceiver(); - onActivated(() => { - set(_metadata); - }); - } -} + // setup handler + receiver?.(metadataGetter); -export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void { - provide(setPageMetadata, callback); -} + // update handler + onBeforeUnmount(watch( + () => toValue(maybeRefOrGetterMetadata), + (metadata) => { + metadataRef.value = metadata; + receiver?.(metadataGetter); + }, + { deep: true }, + )); + onActivated(() => { + receiver?.(metadataGetter); + }); +}; -export function injectPageMetadata(): PageMetadata | undefined { - return inject(pageMetadataProvider); -} +export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => { + setReceiver(receiver); +}; + +export const provideReactiveMetadata = (metadataRef: Ref<PageMetadata | null>): void => { + setMetadata(metadataRef); +}; + +export const injectReactiveMetadata = (): Ref<PageMetadata | null> => { + const metadataRef = getMetadata(); + return isRef(metadataRef) ? metadataRef : ref(null); +}; diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts index cf9fad70eb..8a4e9319b3 100644 --- a/packages/frontend/src/scripts/physics.ts +++ b/packages/frontend/src/scripts/physics.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts index e6c08dfbc0..9e51272791 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/scripts/please-login.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts index 0c2ff16992..1caa2dfc21 100644 --- a/packages/frontend/src/scripts/popout.ts +++ b/packages/frontend/src/scripts/popout.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts index 0a799c5665..8c9e3c02c3 100644 --- a/packages/frontend/src/scripts/popup-position.ts +++ b/packages/frontend/src/scripts/popup-position.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ export function calcPopupPosition(el: HTMLElement, props: { - anchorElement: HTMLElement | null; + anchorElement?: HTMLElement | null; innerMargin: number; direction: 'top' | 'bottom' | 'left' | 'right'; align: 'top' | 'bottom' | 'left' | 'right' | 'center'; diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts index 80441caf15..31a9ac1ad9 100644 --- a/packages/frontend/src/scripts/post-message.ts +++ b/packages/frontend/src/scripts/post-message.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts index 9b13e794f5..7aec05c0cf 100644 --- a/packages/frontend/src/scripts/reaction-picker.ts +++ b/packages/frontend/src/scripts/reaction-picker.ts @@ -1,8 +1,9 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import * as Misskey from 'misskey-js'; import { defineAsyncComponent, Ref, ref } from 'vue'; import { popup } from '@/os.js'; import { defaultStore } from '@/store.js'; @@ -10,6 +11,7 @@ import { defaultStore } from '@/store.js'; class ReactionPicker { private src: Ref<HTMLElement | null> = ref(null); private manualShowing = ref(false); + private targetNote: Ref<Misskey.entities.Note | null> = ref(null); private onChosen?: (reaction: string) => void; private onClosed?: () => void; @@ -23,6 +25,7 @@ class ReactionPicker { src: this.src, pinnedEmojis: reactionsRef, asReactionPicker: true, + targetNote: this.targetNote, manualShowing: this.manualShowing, }, { done: reaction => { @@ -38,8 +41,9 @@ class ReactionPicker { }); } - public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { + public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { this.src.value = src; + this.targetNote.value = targetNote; this.manualShowing.value = true; this.onChosen = onChosen; this.onClosed = onClosed; diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts new file mode 100644 index 0000000000..6bfcef6c36 --- /dev/null +++ b/packages/frontend/src/scripts/safe-parse.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function safeParseFloat(str: unknown): number | null { + if (typeof str !== 'string' || str === '') return null; + const num = parseFloat(str); + if (isNaN(num)) return null; + return num; +} diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts index 625d8c34a7..0edf4e9eba 100644 --- a/packages/frontend/src/scripts/safe-uri-decode.ts +++ b/packages/frontend/src/scripts/safe-uri-decode.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts index 1f626e4c0d..8edb6fca05 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend/src/scripts/scroll.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/search-emoji.ts b/packages/frontend/src/scripts/search-emoji.ts new file mode 100644 index 0000000000..4192a2df8f --- /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.toLowerCase() === 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.toLowerCase() === 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.toLowerCase().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.toLowerCase().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.toLowerCase().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/select-file.ts b/packages/frontend/src/scripts/select-file.ts index 674c762fac..fd7cfc697b 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -1,11 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; @@ -65,7 +66,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { } }); - os.api('drive/files/upload-from-url', { + misskeyApi('drive/files/upload-from-url', { url: url, folderId: defaultStore.state.uploadFolder, marker, diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/scripts/show-moved-dialog.ts index b4defbfe7d..35b3ef79d8 100644 --- a/packages/frontend/src/scripts/show-moved-dialog.ts +++ b/packages/frontend/src/scripts/show-moved-dialog.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts index a2fd5db453..8b89dbb936 100644 --- a/packages/frontend/src/scripts/show-suspended-dialog.ts +++ b/packages/frontend/src/scripts/show-suspended-dialog.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts index d9d5bb1037..fed16bc71c 100644 --- a/packages/frontend/src/scripts/shuffle.ts +++ b/packages/frontend/src/scripts/shuffle.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/scripts/snowfall-effect.ts index a09f02cec0..11fcaa0716 100644 --- a/packages/frontend/src/scripts/snowfall-effect.ts +++ b/packages/frontend/src/scripts/snowfall-effect.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -17,20 +17,20 @@ export class SnowfallEffect { uniform vec3 u_worldSize; uniform float u_gravity; uniform float u_wind; + uniform float u_spin_factor; + uniform float u_turbulence; void main() { v_color = a_color; - v_rotation = a_rotation.x + u_time * a_rotation.y; + v_rotation = a_rotation.x + (u_time * u_spin_factor) * a_rotation.y; vec3 pos = a_position.xyz; - float turbulence = 1.0; - pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x; pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y; - pos.x += sin(u_time * a_speed.z * turbulence) * a_rotation.z; - pos.z += cos(u_time * a_speed.z * turbulence) * a_rotation.z; + pos.x += sin(u_time * a_speed.z * u_turbulence) * a_rotation.z; + pos.z += cos(u_time * a_speed.z * u_turbulence) * a_rotation.z; gl_Position = u_projection * vec4(pos.xyz, a_position.w); gl_PointSize = (a_size / gl_Position.w) * 100.0; @@ -105,6 +105,7 @@ export class SnowfallEffect { private opacity = 1; private size = 4; private snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAErRJREFUeAHdmgnYlmPax5MShaxRKRElPmXJXpaSsRxDU0bTZ+kt65RloiRDltEMQsxYKmS+zzYjxCCamCzV2LchResMIxFRQ1G93+93Pdf5dL9v7zuf4/hm0fc/jt9znddy3/e1nNd53c/7vHXq/AtVWVnZA/bzkaQjoWG298DeMdvrmP6/EIOqC4fBsbAx7Arz4TaYBPXgWVDnO2jSBrB2T0IMIA9mCmmoE8aonPkR6WPZHlp9xSlfeyeBzq9bHBD5feEdUGfDXBgBqnde+a2wvw/dYdNctvZNAp1PnTaFttA6JgP7eVgBM0CNzgO9HNvy0AcYDda6SaDTdXOnz8X+IkZDugAGQmOYA+ob6Ah/MIOMDRPhJjgJ6uV7pXtWt81/50SnY/Wvwn4ZDHAvwJ9ATYcxyaqsnEnqZCyCPaE80BgYZXG/5A3VyyP/b08LHa11z9KmFUwA5eqruRBHYX1s8WSI1Xcbme8Mt8PWUCU+kF8XbFN+dtH+p06OD4IU8EjD/VOZ5bnezq0XHcHuC2oV7BDlkVIWq56uIX8UjAO31GRIMYW0Vo/xXtSXJyTuXVO6xk1qalRTmQ9AfqzEvog2XYpllnsd6Qr4unCPT7NtByu0uU7vuAaOoy1JuvfXpJdTvSX0gI1gCXwGZdFmEFxoQb7Wid8s7lNu+I8wuHGsTqz2zpQ9DAa5R6HC55A2gvCMXthvwi25bjx26H0M9/9f4Rnok9s0zulFlC2HzzP9cnld8nH/p7DVrbmuIfYs6JLz9U3/z+KGadDeCDsmwre7GyEifn/su8HVSsL2HeBn8CK8AW+B7u9R5yrPgyOjvSn5DWAaXAG2UU7CE9Ayt4k4sR1lX4LaLdd9gn2ftsL+Vtuh1Dp/elH1C8lvCdUj8kDK3gbP8XdhCnSC86rcsNSR9pQvhc/gVlB9bUfqoFNAy/mLrUROrpMwCtpBxBbTtLqkF4K6IF9rf57I9pnYekx5AS0P1VhopXso9pR5buC7+kewU86nFcB+BT4EXdIvNO73sRBubGTXLZtTtgp+DEb++bACdqBuJOlAaMMzLVM3whegNznQDtCb+pW5b8YY76euB5+7pxm0IbzCfS8m3Zf2q4T8/+4JNArXGoptpxz8LqDmQJq0Qnostt/sfIn5GygD4/Zeq7B7wljQO2yjB/QGj0Pjxz4wGdqXrkjXtCT/ISyDa6EPpHrSraFjvnecFpMoMx40Br3xSlD262rYObevddHTs2kYwWUG9uP5It/f1eU5Xw9btwoXPALbwYXcg+unG/KB3Rq8n9ddAOpn4Kr8BAaBcltcDo9D7Ouavig1o34x7F94xqPk74eLQH0MH8HvwS3SLPe9iheEG6f70KiuLpZv6sxG/Va5bFJOabaO7ucAvGEbeAH+AN1hV7iDOidQFz4A2oJb6D1YDhXZHkTqpL8EbqHDYRtwW20AsdIb8syl5N2e6dTAPB2mWYa+hE4Qk7I59iMwFZ70GlJlfyuTVfygs7Hyw7HbwI0w3Tak14BqEtdg7wVdIx8pZbtBUbrjZeA3vUPBANkU+sEehev8O4Db6QpwYm+D8II0KPKHwUFeQ3oLDIMN4WgID1yOPQ+MAXMhNAtju3ztmtuAypiAw7EXwo/Am+0NfUG5mknYc6GfGVIjsoFNuyuoh8COuDcd2LmwA9jWE8bB3Q7N4XrwWAz5XOXR+Tx4n6FgdHeB6sF/w2QwhlSXdXvl/jixx4NH8GW5LDzb7GrR4ES4F5QddB99CieAwStOAPegdUZ2B71F3AXbQSn3vJ1bYaYWrayh3NUPTcbYFExVW3CfXwlvgfoavMbnDAY9dxGo6dCt0LeaB54H4UydDEPA2R4PDlrFLB9XuNmTlO+Xr7X9ZNBr9J4+EN8AMcv6ButpMND9FM6EnTOHkLrSnvtzwbbq3vwMB2ow/qWFSC8ZC++ZQaldbquH2afQWbl8TdcvVtC6LtipifAuOKt6gA9Tzqgzb5R2gP1hX3DVtZVHVvdklY5DA5beIkVPuZn8LOgAnWEfeAaUkxCan/voBNkfF+U5cFu5z5XlxZU20OmZtgm1K45VO4naNCukrcBZVk/CD+E/YBjoYjXJY8Zg9DxsDrbbBHTRotxOrug4eBs+hHgWZtKzfHrdXHBi9gDvqzxFHNA5KVfyBCf0ExgB7nkXStLLEKkniNf0AzUs5+ublkVFKiC9FBZAvGxshT0NnN3zoSUYSJQPcjAvm0HmjcIPemNS96F6E36drFLwugx7EEzNZV/l9IjoEPkW4B7eFtYH9QKcBcfA/aCWgpPQOT+zMbb9fS3nDbYR2MdgV0S5aVlUhLs0w45IHi7sqnnGJ2E7CXqHWgZXgJ1y8KqpDUmfSLmSV5yB/XrpDqVP8ofmehNdOv7I0ShfP4yyJdl2a4SchI1gCXgkHgljYfvc1i3cs/SU1A9jQRpfri/b0Sal1RrtSj4ULyHprY5C6+6E1+EBULq0E+DK7A96iwqX0z4td8B3dCdob5gD3UB3j9fUcNuDKFOvgc+bZAZFf4Zgu/q/AGPMgfm+5ShPWay+k6I31BwAvVDRYL2cuqfUVTkfnTqvVFx5ai7/MXn3tp1UrtRkDWRsaAMjzaD08uJ1irz7+8ps/6ZYj90V3FKrQBkvmubULbN7vs7tZRyJV9w0ePLbQ4PcJspqXnkbhbgoGk/AVptZRxpB0hU7Mpc1x34cdgKPm1dzeTts9XPwlFAO5Au4BDbO7ZycO7J9A/Zh2b4A2+ucALefWpTrflDKVq4kHQBOoi9PO1qvsDeGd6AxXAJbQ5VxlFrW8EnDcJlTsOPcjElxL7WNy7AduC4f2+A/rSN/Hyg7YMBTxgqPUT3F2HAqtIb58GvQW86GqyG+ff4UWz0FBuH4UhaTal1vmAGfg98dfP4d4HPGwmwYAg+D2/J7uU0ap/YaolHZVbBj5d1DaSK8ADsmqiH2JIhgNRhbPZrbhSdZ5heVJGw7477VfYuaagMK2sM8iMloga1HXAt/AeWELgQnR/0Z7k3W6pe3xTn/JamTFPGnPMZSj6p90rA8YOziwHcnH/EgTovJlJ0LPSHkyrTKmZNJ+8KrYKBsCQeB0pWdBFNleieMgzjL44jejTK1CPSY0CiMdyOT09g6ni5O3Ceg51U4VNLaPSA3SDNEwwiKFdgHgANNrpjb7UVejYTYCuZ92DR42HYh8gfDJfAMqBi4dqxk+RrKGkD0YXNsA6AT5qCUXhBe5CR0gPCC4dhqKFwI1m1qX0hr94CotDE4aAd3PCyBX4Jyn+sNL5tBDsRAp3S7b5KVYwa2A0nHaO5AXBeDtnlMxizsW+HomLh8zX9R5sTeBSEn/cqc2Tvak9eDXCyP2PgbYWzn2gefHxT7+0Qu/h18DO7XmPWYcYqSXuHz2myb6G7RNs7meLgeMxXugbiPA3clQx0xtgNPGN819L7+oCzvm6zSx+EkI+Du3Pe0LbOd/jqc7dhG9Wib+mJ5jaJBuL8e4B5aAMpAomKlb8d+KZWUVnw+dgzKSdDtvKaLDyJ1ReZB7O0J2EV5Xwd8OsTJExNpu7Q1SJ8zgy7K93UCX4P4mr4udoyhPGDKygOP+tomIFarMw2d+cfgF2DnDVAGoBvzw33YTHgPDoXQ7Fx/Wy6YkdMrcrmrehO4Pz3WvP90cIVPgonwITg4973yu0XTZK0+ZQaQd+K816twVAwKO71ZRj9zeg7lcVzXHghpVN4n2G3BAHQ1NILx4MBjoppgLwL3Ww8IHZsf6vGk3O8fwx9heK7rhD0o2zdg75JtT6GzQQ8KzcZwElSr3M5J85ktYCzEG+Gx2NNzm/Cm5pSp+K2gfLrZbg3RcB2IQcZN1qPM3+l06SjbAltX/TiXe1wtg7+AdR+AcgIs7xUPw94XxuTrnOD4E1bEoe9Rptw+DWGOGeQi7JOs1SfKKfk+epcakPNxbI8uFVdem8vT6aJdq7jASYjOFPdQDP4Q6t+Em8HVutmbkbYH9Tv4LcQW+H6ujy9Wrtxc6A7vQnznb5TbHUPZ0mw7CeoaOBAegmfBIKw8WZzs34M/oNiPGPzB2KHdrVMUlD29VFLLpw2jMWmnaIbdDNxXur+dWgVumTMglI4zMgbUEV5LmjqW7XnRkDS9qhbu/xZlZ8LWuc3UfM22Of80aVcYDJ/lstdIWxXu0TGXm/TO19vveHWuOglUxOo6iMfyBe7JOEp01ech9puuuBCMA8pVcUUNUB5lqgMYwJyE1oXOGTh9v1gO6kmogKEwHtREMHYofz5zAl3lJ2AWqJfgfohJiKB8HWWfg54YA9Zr1fn5Xmm80SdvHhNwVmq2umF8vWxA+WRwwE9BPNhOulrq0nxz97j6Go6DF8HYcBfYyer6MwWuoINeDG6roq4iE97QCtsJuxWc2JrkCeKEbgX7waOgnLiavxdQEWfohtgRwCrygIoxoQv1K0FNgR7gAKPTB+dr5lAWMliqmbAb7AzbgCs42vYK21NmOiwHJ9atpdxqDlhdA75QdYJT4XUYDfbBiVRe5ySoZTAbBpeekp6T4lo5uFnBz0fpJ6P8E9SJufEdXHipdRA/mw2hzmvfhrfgfjCKPwJnwn2g3igldb4hNaD5a6/fz7eHVuAb2wPwPs+4DB7E/hTagd64BbgoC6Ab9IAfgn+OX0p/ppAaGxZjnw6+Ep8DK8Cj0IDrmHw3GaeN9EZ/AlxFfk1RuVGUYu8K00D9Fa6EvrAUVKzO29gXg9vC1VW3g540w0xBcU2hKJnz+FxYvTCXWaduK/StuTZlLcD6JjnfEvsb6A56m32z78q4FMGw1gA4lEa60WmwMeiSnsljIBSDmEOBE3RdfvggbMuMIbNhItgJtbyUpE9ddjA0Bid1sderXDaQ1OdPAO9zH6hDcpuG2Ml7SQfArHRx6Xpf3JTluySrsrIP6Seg9/iMqsEvF6YZoXIDeAZCRmpneAHEnnLQnaEuXATX53schR3n/e7YyuvOT1bpnyV107Io3xZ6QWs4EirAyXkEqqvK3xa9CQ0c5C5xQ+zN8kWjcr2xZxTsBHfmsipbP671ZmW3wHYA58DdEPobhtwVF2HfBE9H3pT8xjkdja3iiDK4PQBO8Dx4B9wiH8JKeANcKTUW9IITwKNMeYrcArfDhVDsb1pVyty26le5D97/zWzrzVUGXyVjI0WjHUgq4CjoAuGiRuuJkN7mSJX7cn+uaZNyfBBgDHZqXvqsU2cZ6aPwChgE/ap8M9wLbSH+0DKOaw18z8N12GPAyf4BfADbwBmwCbxAHY9NvxQXx2GgVLZXPvurZDE0rqk5+NmAm8U2aIbdH9yDalgpSS80ltlB29fPqW9c8XLUHnsIuGquqt8gN7edwtazrOsAn4MysLryX8BD4Ap3y+0dZROIwPsl9h/hHjgit4lXdrdvHN8dc91wyk7JdvIS7VpF46Jb2ZGz4WJIRyBpBKQW3oR8lZuSvwQMhKtAfQUpYuf27cgbNx6EEeDAzgMHPwYMYi2gEcSfxC7B9qicDMoo/1vQI8p9IG88WAY/yeVpYrJdHpf5vytu4Ky7X46xIamrvjDb52OrG3K+HrZt4xq9wYEZPGPVfp7bhsdE2os2ylV6J1n5mbYPUX4S7AkGX+OAk2t6mm1Iw3PtQ+O4LuooK26RYvW3s7nBLZDiAGlbUHYiRV/S5AWk28DTEFqB4eo+B+n1M55Ivhu4kspj92uYCm6Px0Gv61lor0fcDQNBrQQnOr71lVeYsm894L/bkBuFe/u93eBngJtJMlwTDIDKyfDt6n3se8Dt8jHoNU0o70waq34obZ8lPx4coG+LbifrP6Pt0aQvwn65LFzcAHY8ZUtgAnwExp2WoMpeQLvaA12p7bf/pLPFmS3a/ajr750cfE43wX4YYmU9wi7IddHBCsrc69vm8uuwQydYVhQVvmsUn7s+ebfD0GhXrI+yf2jqA4oPKdo+iHxMwHbYRmgjta4cUTqCWXkg0UHatIR4SxxWKK9PeXhgKiZfxWOthzXuGff4p6b54bH3Y3W3pNxJcK8ebgdI44iys0G0N/8qKGOAGg9Ni50n3yjy2GkxSKtMRtT/21I7Fg/H9lRIX6qK5YX6zSjvDL4BGiBfBnUNmFdzwfKX4Ct40OtJv1sDj0Hlzrk6xbM3tob7uCf4amyk96VHvQg7gltGzQG9wpcwX6BCesfJ3/kJiMmgs+Gm4errUeZqF+Up4IoOzoWLcmqETyLve/2BsKkFpGUvK7VYCz6j06RbQx+ogHhN3Qdb3QF+a/wVKF94OhSHR77sWcXytcKm82usHGW9QE2B3skq/QB7APaqnJ9NuvaufnF1GIhxYH3LSAeA+hM0hMfgNzATdHvjgDHDv+qkP8gW77XW2gwmYsJe2F3zZDgxI7NteTo+/1WD/B9Au3Zjh2RyrgAAAABJRU5ErkJggg=='; + private mode = 'snow'; private INITIAL_BUFFERS = () => ({ position: { size: 3, value: [] }, @@ -119,6 +120,8 @@ export class SnowfallEffect { worldSize: { type: 'vec3', value: [0, 0, 0] }, gravity: { type: 'float', value: this.gravity }, wind: { type: 'float', value: 0 }, + spin_factor: { type: 'float', value: this.mode === 'sakura' ? 8 : 1 }, + turbulence: { type: 'float', value: this.mode === 'sakura' ? 2 : 1 }, projection: { type: 'mat4', value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], @@ -153,7 +156,16 @@ export class SnowfallEffect { easing: 0.0005, }; - constructor() { + constructor(options: { + sakura?: boolean; + }) { + if (options.sakura) { + this.mode = 'sakura'; + this.snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDItMDFUMTQ6Mzk6NTYrMDkwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyNC0wMi0wMVQxNDozOTo1NiswOTAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI2NCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjY0IgogICBleGlmOkNvbG9yU3BhY2U9IjEiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iNjQiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjY0IgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjEiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PhldI30AAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRu0sDQRCHP6Mh4oOIWlhYBPHRJBIjiDYWEV+gFjGCr+ZyuUuEJB53JyK2gq2gINr4KvQv0FawFgRFEcTaWtFG5ZwzgQQxs+zst7+dGXZnwRPPqFmrKgzZnG3GRqOB2bn5gO8FDxV46aJRUS1jcnokTln7uJdYsduQW6t83L9Wm9QsFSqqhQdVw7SFx4QnVm3D5R3hZjWtJIXPhIOmXFD4ztUTeX5xOZXnL5fNeGwIPA3CgVQJJ0pYTZtZYXk57dnMilq4j/uSOi03My1rm8xWLGKMEiXAOMMM0UcPA+L7CBGhW3aUyQ//5k+xLLmqeIM1TJZIkcYmKOqKVNdk1UXXZGRYc/v/t6+W3hvJV6+LgvfZcd46wLcN31uO83nkON/HUPkEl7li/vIh9L+LvlXU2g/AvwHnV0UtsQsXm9DyaCim8itVyvToOryeQv0cNN1AzUK+Z4VzTh4gvi5fdQ17+9Ap8f7FHyc6Z8kcDq1+AAAACXBIWXMAAAsTAAALEwEAmpwYAAADwElEQVR4nO2bT4hWVRjGf75TkhoEkhSa/9ocRIIwCsrE1pVnLbkYdFdGgQRS6caVm3CVy2oRuqmQ2yJXKTJh4GqCGs/CJCcLccAJ/yDpnGnxHYeZ4TrNfOc55y78nuWdc3/ve57v+b65f86BgQaqotiE5bEJKxYx7onYhOU1egKwGkViE/YCN4Cx2ITNC4xbDVwAJmMT9tXobVnpArEJe4CvZx0aB7aZdxPzxhkwArw66/Ae8+5Eyf6KJiA2YRPw+bzD64EjLcP3MXfyAMdjEzYWaG1GxRIQmzAEnAVeb/nzFPCSeTeaxj4FBOCZlrEjwBvm3VSJPksm4BPaJw8wBHwXm/BibMIW4HvaJ09ifFygP6BQAtKkfgEeEyHvAy+YdxdFvBmVSsBBdJMnsQ4KeTOSJyA2YT1wCXhcjL4HPG/e/amElkjAAfSTJzEPqKHSBKQLmSvAKiV3lm4BG8y7GyqgOgHvU27yAE+mGjLJEhCbsBL4A3haxXyIJoCN5t0dBUyZgF2UnzypxtsqmNKAt4SsarUkX4F0I3ONOgkAuA48a97FXJAqAa9Qb/IAa4CXFSCVATXjL635yBuQ/RsQm7AWuCroZamaBtaZd3/nQBQJeFPA6EfLFLUVBrwmYPSr7bkAhQHPCRj9al0uQGHAWgGjs9oKA7I/hS5rZ/0XSC86JDclGVph3t3t9+TcBHT56T9QVg+5BnT5/X+grB4GCcgs/sgnYCjzfIWyesg14Hrm+Qpl9ZBrwMT/DymurB4GCeiyuEidGnCN3n15V5pOPfStLAPMu1vAWA4jU7+Zd7dzAIqboREBo7PaCgN+EjA6qz1IQDbAu9/prQeorUvm3eVciOqx+JcizlL0hQKiMuAreiu/amkq1cyWxADz7ipwWsFapH4w7/5SgJRvh+cviCyp4yqQeonMOWCHktmic+bdThVMvUSmyFK2kjWkBph354FTSuY8nTLvflYCSyyT+xD4pwB3EvhADZUbYN5dAfarucB+825cDS25WvwksFuEO2nevSNizVHJ1eLvAoplrePAewJOq4oZYN5NAsPkPTCZBoYTq4iK7hgx734EjmUgjpl3Z1T9tKnGpqlP6e+p0Vg6t6iKG5De3A6ztJul+/Si3/db38WqyrY58+4CcHQJpxxN5xRXFQOSjgCjixg3SvuusiKqZoB59y+964KbCwy7Cew27+7V6apuAkibnhbaEbq3xMaohVTVAADz7hvgMHN/FKeAQ+bdt7X7Kb519mGKTdgKfEbvYucj8+7XLvr4DxAA134c0w/5AAAAAElFTkSuQmCC'; + this.size = 10; + this.density = 1 / 280; + } + const canvas = this.initCanvas(); const gl = canvas.getContext('webgl2', { antialias: true }); if (gl == null) throw new Error('Failed to get WebGL context'); diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 2f7545ef0d..fcd59510df 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -1,11 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import type { SoundStore } from '@/store.js'; import { defaultStore } from '@/store.js'; -import * as os from '@/os.js'; let ctx: AudioContext; const cache = new Map<string, AudioBuffer>(); @@ -89,63 +88,33 @@ export type OperationType = typeof operationTypes[number]; /** * 音声を読み込む - * @param soundStore サウンド設定 + * @param url url * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする */ -export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) { - if (_DEV_) console.log('loading audio. opts:', options); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { - return; - } +export async function loadAudio(url: string, options?: { useCache?: boolean; }) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ctx == null) { ctx = new AudioContext(); } if (options?.useCache ?? true) { - if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) { - if (_DEV_) console.log('use cache'); - return cache.get(soundStore.fileId) as AudioBuffer; - } else if (cache.has(soundStore.type)) { - if (_DEV_) console.log('use cache'); - return cache.get(soundStore.type) as AudioBuffer; + if (cache.has(url)) { + return cache.get(url) as AudioBuffer; } } let response: Response; - if (soundStore.type === '_driveFile_') { - try { - response = await fetch(soundStore.fileUrl); - } catch (err) { - try { - // URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック - const apiRes = await os.api('drive/files/show', { - fileId: soundStore.fileId, - }); - response = await fetch(apiRes.url); - } catch (fbErr) { - // それでも無理なら諦める - return; - } - } - } else { - try { - response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`); - } catch (err) { - return; - } + try { + response = await fetch(url); + } catch (err) { + return; } const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer); if (options?.useCache ?? true) { - if (soundStore.type === '_driveFile_') { - cache.set(soundStore.fileId, audioBuffer); - } else { - cache.set(soundStore.type, audioBuffer); - } + cache.set(url, audioBuffer); } return audioBuffer; @@ -155,13 +124,12 @@ export async function loadAudio(soundStore: SoundStore, options?: { useCache?: b * 既定のスプライトを再生する * @param type スプライトの種類を指定 */ -export function play(operationType: OperationType) { +export function playMisskeySfx(operationType: OperationType) { const sound = defaultStore.state[`sound_${operationType}`]; - if (_DEV_) console.log('play', operationType, sound); - if (sound.type == null || !canPlay) return; + if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return; canPlay = false; - playFile(sound).finally(() => { + playMisskeySfxFile(sound).finally(() => { // ごく短時間に音が重複しないように setTimeout(() => { canPlay = true; @@ -173,26 +141,59 @@ export function play(operationType: OperationType) { * サウンド設定形式で指定された音声を再生する * @param soundStore サウンド設定 */ -export async function playFile(soundStore: SoundStore) { - const buffer = await loadAudio(soundStore); +export async function playMisskeySfxFile(soundStore: SoundStore) { + if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { + return; + } + const masterVolume = defaultStore.state.sound_masterVolume; + if (isMute() || masterVolume === 0 || soundStore.volume === 0) { + return; + } + const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`; + const buffer = await loadAudio(url); if (!buffer) return; - createSourceNode(buffer, soundStore.volume)?.start(); + const volume = soundStore.volume * masterVolume; + createSourceNode(buffer, { volume }).soundSource.start(); } -export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { - const masterVolume = defaultStore.state.sound_masterVolume; - if (isMute() || masterVolume === 0 || volume === 0) { - return null; +export async function playUrl(url: string, opts: { + volume?: number; + pan?: number; + playbackRate?: number; +}) { + if (opts.volume === 0) { + return; } + const buffer = await loadAudio(url); + if (!buffer) return; + createSourceNode(buffer, opts).soundSource.start(); +} + +export function createSourceNode(buffer: AudioBuffer, opts: { + volume?: number; + pan?: number; + playbackRate?: number; +}): { + soundSource: AudioBufferSourceNode; + panNode: StereoPannerNode; + gainNode: GainNode; +} { + const panNode = ctx.createStereoPanner(); + panNode.pan.value = opts.pan ?? 0; const gainNode = ctx.createGain(); - gainNode.gain.value = masterVolume * volume; + + gainNode.gain.value = opts.volume ?? 1; const soundSource = ctx.createBufferSource(); soundSource.buffer = buffer; - soundSource.connect(gainNode).connect(ctx.destination); + soundSource.playbackRate.value = opts.playbackRate ?? 1; + soundSource + .connect(panNode) + .connect(gainNode) + .connect(ctx.destination); - return soundSource; + return { soundSource, panNode, gainNode }; } /** diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts index f233c3648e..50f1e6ecc8 100644 --- a/packages/frontend/src/scripts/sticky-sidebar.ts +++ b/packages/frontend/src/scripts/sticky-sidebar.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/test-utils.ts b/packages/frontend/src/scripts/test-utils.ts index 1b42811faa..52bb2d94e0 100644 --- a/packages/frontend/src/scripts/test-utils.ts +++ b/packages/frontend/src/scripts/test-utils.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts index 275f4bcdaa..0092af1640 100644 --- a/packages/frontend/src/scripts/theme-editor.ts +++ b/packages/frontend/src/scripts/theme-editor.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts index a174f51756..c49593ed42 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/scripts/theme.ts @@ -1,11 +1,12 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { ref } from 'vue'; import tinycolor from 'tinycolor2'; import { deepClone } from './clone.js'; +import type { BuiltinTheme } from 'shiki'; import { globalEvents } from '@/events.js'; import lightTheme from '@/themes/_light.json5'; import darkTheme from '@/themes/_dark.json5'; @@ -18,6 +19,13 @@ export type Theme = { desc?: string; base?: 'dark' | 'light'; props: Record<string, string>; + codeHighlighter?: { + base: BuiltinTheme; + overrides?: Record<string, any>; + } | { + base: '_none_'; + overrides: Record<string, any>; + }; }; export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); @@ -57,7 +65,7 @@ export const getBuiltinThemesRef = () => { const themeFontFaceName = 'sharkey-theme-font-face'; -let timeout = null; +let timeout: number | null = null; export function applyTheme(theme: Theme, persist = true) { if (timeout) window.clearTimeout(timeout); diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts index 4479db1081..275b67ed00 100644 --- a/packages/frontend/src/scripts/time.ts +++ b/packages/frontend/src/scripts/time.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts index 55f9be393f..c7582e06da 100644 --- a/packages/frontend/src/scripts/timezones.ts +++ b/packages/frontend/src/scripts/timezones.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts index 05f379e4aa..13c9d648dc 100644 --- a/packages/frontend/src/scripts/touch.ts +++ b/packages/frontend/src/scripts/touch.ts @@ -1,8 +1,9 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import { ref } from 'vue'; import { deviceKind } from '@/scripts/device-kind.js'; const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; @@ -16,3 +17,6 @@ if (isTouchSupported && !isTouchUsing) { isTouchUsing = true; }, { passive: true }); } + +/** (MkHorizontalSwipe) 横スワイプ中か? */ +export const isHorizontalSwipeSwiping = ref(false); diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts index 65fc090888..a24941d02e 100644 --- a/packages/frontend/src/scripts/unison-reload.ts +++ b/packages/frontend/src/scripts/unison-reload.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts index b896376ec8..6c46b2bc1b 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/scripts/upload.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { readAndCompressImage } from 'browser-image-resizer'; +import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { getCompressionConfig } from './upload/compress-config.js'; import { defaultStore } from '@/store.js'; import { apiUrl } from '@/config.js'; diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts index 2deb9cbb81..3046b7f518 100644 --- a/packages/frontend/src/scripts/upload/compress-config.ts +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import isAnimated from 'is-file-animated'; import { isWebpSupported } from './isWebpSupported.js'; -import type { BrowserImageResizerConfig } from 'browser-image-resizer'; +import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; const compressTypeMap = { 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, @@ -21,7 +21,7 @@ const compressTypeMapFallback = { 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, } as const; -export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> { +export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> { const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; if (!imgConfig || await isAnimated(file)) { return; diff --git a/packages/frontend/src/scripts/upload/isWebpSupported.ts b/packages/frontend/src/scripts/upload/isWebpSupported.ts index 185c3e6b40..2511236ecc 100644 --- a/packages/frontend/src/scripts/upload/isWebpSupported.ts +++ b/packages/frontend/src/scripts/upload/isWebpSupported.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts index 625f4ce057..e3072b3b7d 100644 --- a/packages/frontend/src/scripts/url.ts +++ b/packages/frontend/src/scripts/url.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts index 3d6489c3b8..7e4bf5c9c6 100644 --- a/packages/frontend/src/scripts/use-chart-tooltip.ts +++ b/packages/frontend/src/scripts/use-chart-tooltip.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend/src/scripts/use-document-visibility.ts index a9e2512eb3..a8f4d5e03a 100644 --- a/packages/frontend/src/scripts/use-document-visibility.ts +++ b/packages/frontend/src/scripts/use-document-visibility.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts index b8c5431fb6..b50e78c3cc 100644 --- a/packages/frontend/src/scripts/use-interval.ts +++ b/packages/frontend/src/scripts/use-interval.ts @@ -1,9 +1,9 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { onMounted, onUnmounted } from 'vue'; +import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; export function useInterval(fn: () => void, interval: number, options: { immediate: boolean; @@ -28,6 +28,16 @@ export function useInterval(fn: () => void, interval: number, options: { intervalId = null; }; + onActivated(() => { + if (intervalId) return; + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + + onDeactivated(() => { + clear(); + }); + onUnmounted(() => { clear(); }); diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts index c9750c3923..5f7e56e8a9 100644 --- a/packages/frontend/src/scripts/use-leave-guard.ts +++ b/packages/frontend/src/scripts/use-leave-guard.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index bcdba5455a..3baa45d50f 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -1,16 +1,17 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { onUnmounted, Ref } from 'vue'; +import { onUnmounted, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { useStream } from '@/stream.js'; import { $i } from '@/account.js'; import * as os from '@/os.js'; +import { misskeyApi } from './misskey-api.js'; export function useNoteCapture(props: { - rootEl: Ref<HTMLElement>; + rootEl: ShallowRef<HTMLElement | undefined>; note: Ref<Misskey.entities.Note>; pureNote: Ref<Misskey.entities.Note>; isDeletedRef: Ref<boolean>; @@ -32,7 +33,7 @@ export function useNoteCapture(props: { // notes/show may throw if the current user can't see the note try { - const replyNote = await os.api('notes/show', { + const replyNote = await misskeyApi('notes/show', { noteId: body.id, }); @@ -100,7 +101,7 @@ export function useNoteCapture(props: { case 'updated': { try { - const editedNote = await os.api('notes/show', { + const editedNote = await misskeyApi('notes/show', { noteId: id, }); @@ -121,7 +122,7 @@ export function useNoteCapture(props: { function capture(withHandler = false): void { if (connection) { // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id }); + connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts index aaf0a0285a..a26d08cce7 100644 --- a/packages/frontend/src/scripts/use-tooltip.ts +++ b/packages/frontend/src/scripts/use-tooltip.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/scripts/worker-multi-dispatch.ts b/packages/frontend/src/scripts/worker-multi-dispatch.ts index 7686b687c5..6b3fcd9383 100644 --- a/packages/frontend/src/scripts/worker-multi-dispatch.ts +++ b/packages/frontend/src/scripts/worker-multi-dispatch.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 18cfad2102..2cf17b27c5 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,7 +7,9 @@ import { markRaw, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { miLocalStorage } from './local-storage.js'; import type { SoundType } from '@/scripts/sound.js'; +import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki'; import { Storage } from '@/pizzax.js'; +import { hemisphere } from '@/scripts/intl-const.js'; interface PostFormAction { title: string, @@ -151,6 +153,14 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: true, }, + showVisibilitySelectorOnBoost: { + where: 'account', + default: true, + }, + visibilityOnBoost: { + where: 'account', + default: 'public' as 'public' | 'home' | 'followers', + }, menu: { where: 'deviceAccount', @@ -204,6 +214,13 @@ export const defaultStore = markRaw(new Storage('base', { default: { src: 'home' as 'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`, userList: null as Misskey.entities.UserList | null, + filter: { + withReplies: true, + withRenotes: true, + withBots: true, + withSensitive: true, + onlyFiles: false, + }, }, }, pinnedUserLists: { @@ -247,6 +264,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + warnMissingAltText: { + where: 'device', + default: true, + }, imageNewTab: { where: 'device', default: false, @@ -391,6 +412,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + oneko: { + where: 'device', + default: false, + }, clickToOpen: { where: 'device', default: true, @@ -427,14 +452,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - tlWithReplies: { - where: 'device', - default: false, - }, - tlWithBots: { - where: 'device', - default: true, - }, defaultWithReplies: { where: 'account', default: false, @@ -460,6 +477,21 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + dropAndFusion: { + where: 'device', + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, + }, + hemisphere: { + where: 'device', + default: hemisphere as 'N' | 'S', + }, + enableHorizontalSwipe: { + where: 'device', + default: true, + }, sound_masterVolume: { where: 'device', diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index 5f0826b4e3..0c5ee06197 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index fd2716bf9d..d876009961 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -9,7 +9,7 @@ } /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -37,6 +37,9 @@ --margin: var(--marginHalf); } + --avatar: 48px; + --thread-width: 2px; + //--ad: rgb(255 169 0 / 10%); } @@ -250,6 +253,10 @@ rt { line-height: inherit; max-width: 100%; + &:hover { + text-decoration: none; + } + &:focus-visible { outline: none; } @@ -444,6 +451,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; } @@ -452,13 +492,13 @@ rt { transform: scale(0.9); } -@keyframes blink { +@keyframes global-blink { 0% { opacity: 1; transform: scale(1); } 30% { opacity: 1; transform: scale(1); } 90% { opacity: 0; transform: scale(0.5); } } -@keyframes tada { +@keyframes global-tada { from { transform: scale3d(1, 1, 1); } @@ -488,7 +528,7 @@ rt { ._anime_bounce { will-change: transform; - animation: bounce ease 0.7s; + animation: global-bounce ease 0.7s; animation-iteration-count: 1; transform-origin: 50% 50%; } @@ -500,7 +540,7 @@ rt { transition: transform 0.1s ease; } -@keyframes bounce { +@keyframes global-bounce { 0% { transform: scaleX(0.90) scaleY(0.90) ; } diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts index f37c01cca1..c41cc17652 100644 --- a/packages/frontend/src/theme-store.ts +++ b/packages/frontend/src/theme-store.ts @@ -1,11 +1,11 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { Theme, getBuiltinThemes } from '@/scripts/theme.js'; import { miLocalStorage } from '@/local-storage.js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i } from '@/account.js'; const lsCacheKey = $i ? `themes:${$i.id}` as const : null; @@ -19,7 +19,7 @@ export async function fetchThemes(): Promise<void> { if ($i == null) return; try { - const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' }); + const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }); miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); } catch (err) { if (err.code === 'NO_SUCH_KEY') return; @@ -35,13 +35,13 @@ export async function addTheme(theme: Theme): Promise<void> { } await fetchThemes(); const themes = getThemes().concat(theme); - await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); } export async function removeTheme(theme: Theme): Promise<void> { if ($i == null) return; const themes = getThemes().filter(t => t.id !== theme.id); - await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); } diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend/src/themes/_dark.json5 index 3f5822977a..7b70aa1e09 100644 --- a/packages/frontend/src/themes/_dark.json5 +++ b/packages/frontend/src/themes/_dark.json5 @@ -30,6 +30,7 @@ panelHeaderFg: '@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--divider)', + thread: ':lighten<12<@panel', acrylicPanel: ':alpha<0.5<@panel', windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', @@ -94,4 +95,8 @@ X16: ':alpha<0.7<@panel', X17: ':alpha<0.8<@bg', }, + + codeHighlighter: { + base: 'one-dark-pro', + }, } diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend/src/themes/_light.json5 index 6ebfcaafeb..d797aec734 100644 --- a/packages/frontend/src/themes/_light.json5 +++ b/packages/frontend/src/themes/_light.json5 @@ -30,6 +30,7 @@ panelHeaderFg: '@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--divider)', + thread: ':darken<12<@panel', acrylicPanel: ':alpha<0.5<@panel', windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', @@ -94,4 +95,8 @@ X16: ':alpha<0.7<@panel', X17: ':alpha<0.8<@bg', }, + + codeHighlighter: { + base: 'catppuccin-latte', + }, } diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend/src/themes/l-sushi.json5 index e787d63734..f1523b698c 100644 --- a/packages/frontend/src/themes/l-sushi.json5 +++ b/packages/frontend/src/themes/l-sushi.json5 @@ -14,6 +14,6 @@ renote: '@accent', link: '@accent', mention: '@accent', - hashtag: '#229e82', + hashtag: '@accent', }, } diff --git a/packages/frontend/src/type.ts b/packages/frontend/src/type.ts new file mode 100644 index 0000000000..9c0fc2a11e --- /dev/null +++ b/packages/frontend/src/type.ts @@ -0,0 +1,3 @@ +export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] }; + +export type WithNonNullable<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]> }; diff --git a/packages/frontend/src/types/date-separated-list.ts b/packages/frontend/src/types/date-separated-list.ts index 678193ca98..af685cff12 100644 --- a/packages/frontend/src/types/date-separated-list.ts +++ b/packages/frontend/src/types/date-separated-list.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index f4516bbe5b..712f3464e5 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -1,10 +1,10 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import * as Misskey from 'misskey-js'; -import { Ref } from 'vue'; +import { ComputedRef, Ref } from 'vue'; export type MenuAction = (ev: MouseEvent) => void; @@ -15,7 +15,7 @@ export type MenuLink = { type: 'link', to: string, text: string, icon?: string, export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean | Ref<boolean> }; -export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; export type MenuPending = { type: 'pending' }; diff --git a/packages/frontend/src/types/page-header.ts b/packages/frontend/src/types/page-header.ts index 295b97a7fd..e9807a2939 100644 --- a/packages/frontend/src/types/page-header.ts +++ b/packages/frontend/src/types/page-header.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue index 913fa35cc2..b49eff9148 100644 --- a/packages/frontend/src/ui/_common_/announcements.vue +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index a3adbfb1b1..3d5b42241e 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -97,7 +97,13 @@ export function openInstanceMenu(ev: MouseEvent) { action: () => { window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { + } : undefined, (instance.donationUrl) ? { + text: i18n.ts.donation, + icon: 'ph-hand-coins ph-bold ph-lg', + action: () => { + window.open(instance.donationUrl, '_blank', 'noopener'); + }, + } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl && !instance.donationUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, icon: 'ph-question ph-bold ph-lg', action: () => { diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 6ece7d86d7..4fe53ae6a3 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -42,6 +42,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="dev" id="devTicker"><span>DEV BUILD</span></div> <div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div> + +<SkOneko v-if="defaultStore.state.oneko"/> </template> <script lang="ts" setup> @@ -49,7 +51,8 @@ import { defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; -import { popups, pendingApiRequestsCount } from '@/os.js'; +import { popups } from '@/os.js'; +import { pendingApiRequestsCount } from '@/scripts/misskey-api.js'; import { uploads } from '@/scripts/upload.js'; import * as sound from '@/scripts/sound.js'; import { $i } from '@/account.js'; @@ -58,6 +61,8 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { globalEvents } from '@/events.js'; +const SkOneko = defineAsyncComponent(() => import('@/components/SkOneko.vue')); + const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); @@ -82,7 +87,7 @@ function onNotification(notification: Misskey.entities.Notification, isClient = }, 6000); } - sound.play('notification'); + sound.playMisskeySfx('notification'); } if ($i) { diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 618be2db88..85340fa2b7 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.bottom"> <button class="_button" :class="$style.post" data-cy-open-post-form @click="os.post"> - <i :class="$style.postIcon" class="ph-pencil ph-bold ph-lg ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span> + <i :class="$style.postIcon" class="ph-pencil-simple ph-bold ph-lg ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span> </button> <button class="_button" :class="$style.account" @click="openAccountMenu"> <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct :class="$style.acct" class="_nowrap" :user="$i"/> @@ -254,7 +254,7 @@ function more() { left: 20px; color: var(--navIndicator); font-size: 8px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; &:has(.itemIndicateValueIcon) { animation: none; diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 20d4564770..65763bcfa8 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.bottom"> <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="os.post"> - <i class="ph-pencil ph-bold ph-lg ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> + <i class="ph-pencil-simple ph-bold ph-lg ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> <button v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> @@ -313,7 +313,7 @@ function more(ev: MouseEvent) { left: 20px; color: var(--navIndicator); font-size: 8px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; &:has(.itemIndicateValueIcon) { animation: none; @@ -483,7 +483,7 @@ function more(ev: MouseEvent) { left: 24px; color: var(--navIndicator); font-size: 8px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; &:has(.itemIndicateValueIcon) { animation: none; diff --git a/packages/frontend/src/ui/_common_/notification.vue b/packages/frontend/src/ui/_common_/notification.vue index dc1a9a1b24..29ae04387a 100644 --- a/packages/frontend/src/ui/_common_/notification.vue +++ b/packages/frontend/src/ui/_common_/notification.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index c92695afed..8dad666623 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; @@ -52,7 +52,7 @@ const fetching = ref(true); const key = ref(0); const tick = () => { - os.api('federation/instances', { + misskeyApi('federation/instances', { sort: '+latestRequestReceivedAt', limit: 30, }).then(res => { diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 58e109ad7f..b973a4fd6b 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 6057174ba8..67f8b109c4 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { notePage } from '@/filters/note.js'; @@ -54,7 +54,7 @@ const key = ref(0); const tick = () => { if (props.userListId == null) return; - os.api('notes/user-list-timeline', { + misskeyApi('notes/user-list-timeline', { listId: props.userListId, }).then(res => { notes.value = res; diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue index 81445df1e9..872c69810c 100644 --- a/packages/frontend/src/ui/_common_/statusbars.vue +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index 4cb773d28a..968c3969bb 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts index 5239b76705..ff851ad99f 100644 --- a/packages/frontend/src/ui/_common_/sw-inject.ts +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -1,13 +1,14 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { api, post } from '@/os.js'; +import { post } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i, login } from '@/account.js'; import { getAccountFromId } from '@/scripts/get-account-from-id.js'; -import { mainRouter } from '@/router.js'; import { deepClone } from '@/scripts/clone.js'; +import { mainRouter } from '@/router/main.js'; export function swInject() { navigator.serviceWorker.addEventListener('message', async ev => { @@ -30,10 +31,10 @@ export function swInject() { // プッシュ通知から来たreply,renoteはtruncateBodyが通されているため、 // 完全なノートを取得しなおす if (props.reply) { - props.reply = await api('notes/show', { noteId: props.reply.id }); + props.reply = await misskeyApi('notes/show', { noteId: props.reply.id }); } if (props.renote) { - props.renote = await api('notes/show', { noteId: props.renote.id }); + props.renote = await misskeyApi('notes/show', { noteId: props.renote.id }); } return post(props); } diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue index eb8c114f17..244bac6f10 100644 --- a/packages/frontend/src/ui/_common_/upload.vue +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index f0e0271128..527670e103 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <div class="post" @click="os.post()"> <MkButton class="button" gradate full rounded> - <i class="ph-pencil ph-bold ph-lg ti-fw"></i> + <i class="ph-pencil-simple ph-bold ph-lg ti-fw"></i> </MkButton> </div> </div> @@ -141,7 +141,7 @@ onMounted(() => { left: 0; color: var(--navIndicator); font-size: 8px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; } &:hover { diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue index 4fa8f1b434..25b9095574 100644 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <div class="post" data-cy-open-post-form @click="os.post"> <MkButton class="button" gradate full rounded> - <i class="ph-pencil ph-bold ph-lg ti-fw"></i><span v-if="!iconOnly" class="text">{{ i18n.ts.note }}</span> + <i class="ph-pencil-simple ph-bold ph-lg ti-fw"></i><span v-if="!iconOnly" class="text">{{ i18n.ts.note }}</span> </MkButton> </div> <div class="divider"></div> @@ -221,7 +221,7 @@ watch(defaultStore.reactiveState.menuDisplay, () => { left: 0; color: var(--navIndicator); font-size: 8px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; &:has(.itemIndicateValueIcon) { animation: none; diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index 3bb9097985..ea9ea56b90 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -52,19 +52,21 @@ import XCommon from './_common_/common.vue'; import { instanceName } from '@/config.js'; import { StickySidebar } from '@/scripts/sticky-sidebar.js'; import * as os from '@/os.js'; -import { mainRouter } from '@/router.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; +import { mainRouter } from '@/router/main.js'; const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); +const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); + const DESKTOP_THRESHOLD = 1100; const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); -const pageMetadata = ref<null | PageMetadata>(); +const pageMetadata = ref<null | PageMetadata>(null); const widgetsShowing = ref(false); const fullView = ref(false); const globalHeaderHeight = ref(0); @@ -75,12 +77,18 @@ const widgetsLeft = ref<HTMLElement>(); const widgetsRight = ref<HTMLElement>(); provide('router', mainRouter); -provideMetadataReceiver((info) => { - pageMetadata.value = info.value; +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); + pageMetadata.value = info; if (pageMetadata.value) { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + if (isRoot.value && pageMetadata.value.title === instanceName) { + document.title = pageMetadata.value.title; + } else { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } } }); +provideReactiveMetadata(pageMetadata); provide('shouldHeaderThin', showMenuOnTop.value); provide('forceSpacerMin', true); diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 0df814fc88..68c7f0fcd2 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> </span> </button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ph-pencil ph-bold ph-lg"></i></button> + <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ph-pencil-simple ph-bold ph-lg"></i></button> </div> <Transition @@ -103,7 +103,6 @@ import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { defaultStore } from '@/store.js'; @@ -117,6 +116,8 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue'; 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')); @@ -189,7 +190,7 @@ const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ title: i18n.ts._deck.addColumn, items: columns.map(column => ({ - value: column, text: i18n.t('_deck._columns.' + column), + value: column, text: i18n.ts._deck._columns[column], })), }); if (canceled) return; @@ -197,7 +198,7 @@ const addColumn = async (ev) => { addColumnToStore({ type: column, id: uuid(), - name: i18n.t('_deck._columns.' + column), + name: i18n.ts._deck._columns[column], width: 330, }); }; @@ -221,42 +222,41 @@ document.documentElement.style.scrollBehavior = 'auto'; loadDeck(); function changeProfile(ev: MouseEvent) { - const items = ref([{ + let items: MenuItem[] = [{ text: deckStore.state.profile, - active: true.valueOf, - }]); + active: true, + action: () => {}, + }]; getProfiles().then(profiles => { - items.value = [{ - text: deckStore.state.profile, - active: true.valueOf, - }, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ + items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ text: k, action: () => { deckStore.set('profile', k); unisonReload(); }, - }))), { type: 'divider' }, { + }))), { type: 'divider' as const }, { text: i18n.ts._deck.newProfile, icon: 'ph-plus ph-bold ph-lg', action: async () => { const { canceled, result: name } = await os.inputText({ title: i18n.ts._deck.profile, - allowEmpty: false, + minLength: 1, }); if (canceled) return; deckStore.set('profile', name); unisonReload(); }, - }]; + }); + }).then(() => { + os.popupMenu(items, ev.currentTarget ?? ev.target); }); - os.popupMenu(items, ev.currentTarget ?? ev.target); } async function deleteProfile() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }), + text: i18n.tsx.deleteAreYouSure({ x: deckStore.state.profile }), }); if (canceled) return; @@ -325,7 +325,7 @@ body { } .rootIsMobile { - padding-bottom: 100px; + padding-bottom: 58px; } .main { @@ -446,20 +446,20 @@ body { .navButton { position: relative; padding: 0; - aspect-ratio: 1; + height: 32px; width: 100%; max-width: 60px; margin: auto; - border-radius: var(--radius-full); - background: var(--panel); + border-radius: var(--radius-lg); + background: transparent; color: var(--fg); &:hover { - background: var(--panelHighlight); + color: var(--accent); } &:active { - background: var(--X2); + color: var(--accent); } } @@ -470,15 +470,17 @@ body { &:hover { background: linear-gradient(90deg, var(--X8), var(--X8)); + color: var(--fgOnAccent); } &:active { background: linear-gradient(90deg, var(--X8), var(--X8)); + color: var(--fgOnAccent); } } .navButtonIcon { - font-size: 18px; + font-size: 16px; vertical-align: middle; } @@ -488,7 +490,7 @@ body { left: 0; color: var(--indicator); font-size: 16px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; &:has(.itemIndicateValueIcon) { animation: none; diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 7cd1d6aee9..79c7c48073 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,6 +19,7 @@ import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -35,7 +36,7 @@ onMounted(() => { }); async function setAntenna() { - const antennas = await os.api('antennas/list'); + const antennas = await misskeyApi('antennas/list'); const { canceled, result: antenna } = await os.select({ title: i18n.ts.selectAntenna, items: antennas.map(x => ({ @@ -55,7 +56,7 @@ function editAntenna() { const menu = [ { - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.selectAntenna, action: setAntenna, }, diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 95ed900f7d..984de82c3f 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -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="ph-pencil ph-bold ph-lg"></i></MkButton> + <MkButton primary gradate rounded inline small @click="post"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkButton> </div> <MkTimeline ref="timeline" src="channel" :channel="column.channelId"/> </template> @@ -26,6 +26,7 @@ import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -41,7 +42,7 @@ if (props.column.channelId == null) { } async function setChannel() { - const channels = await os.api('channels/my-favorites', { + const channels = await misskeyApi('channels/my-favorites', { limit: 100, }); const { canceled, result: channel } = await os.select({ @@ -60,7 +61,7 @@ async function setChannel() { async function post() { if (!channel.value || channel.value.id !== props.column.channelId) { - channel.value = await os.api('channels/show', { + channel.value = await misskeyApi('channels/show', { channelId: props.column.channelId, }); } @@ -71,7 +72,7 @@ async function post() { } const menu = [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.selectChannel, action: setChannel, }]; diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 9ed7e452e3..f9efb9d88c 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index e68b7bba8c..6c4e2fd52b 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,7 +7,7 @@ import { throttle } from 'throttle-debounce'; import { markRaw } from 'vue'; import { notificationTypes } from 'misskey-js'; import { Storage } from '@/pizzax.js'; -import { api } from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { deepClone } from '@/scripts/clone.js'; type ColumnWidget = { @@ -70,7 +70,7 @@ export const loadDeck = async () => { let deck; try { - deck = await api('i/registry/get', { + deck = await misskeyApi('i/registry/get', { scope: ['client', 'deck', 'profiles'], key: deckStore.state.profile, }); @@ -95,7 +95,7 @@ export const loadDeck = async () => { // TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する export const saveDeck = throttle(1000, () => { - api('i/registry/set', { + misskeyApi('i/registry/set', { scope: ['client', 'deck', 'profiles'], key: deckStore.state.profile, value: { @@ -106,13 +106,13 @@ export const saveDeck = throttle(1000, () => { }); export async function getProfiles(): Promise<string[]> { - return await api('i/registry/keys', { + return await misskeyApi('i/registry/keys', { scope: ['client', 'deck', 'profiles'], }); } export async function deleteProfile(key: string): Promise<void> { - return await api('i/registry/remove', { + return await misskeyApi('i/registry/remove', { scope: ['client', 'deck', 'profiles'], key: key, }); diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index e2a212be46..09412ce386 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 45ecc476e7..128562823b 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,6 +19,7 @@ import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -40,7 +41,7 @@ watch(withRenotes, v => { }); async function setList() { - const lists = await os.api('users/lists/list'); + const lists = await misskeyApi('users/lists/list'); const { canceled, result: list } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ @@ -60,7 +61,7 @@ function editList() { const menu = [ { - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.selectList, action: setList, }, diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index cd567040f4..847dcf247a 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -1,14 +1,14 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked"> <template #header> - <template v-if="pageMetadata?.value"> - <i :class="pageMetadata?.value.icon"></i> - {{ pageMetadata?.value.title }} + <template v-if="pageMetadata"> + <i :class="pageMetadata.icon"></i> + {{ pageMetadata.title }} </template> </template> @@ -19,15 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ComputedRef, provide, shallowRef, ref } from 'vue'; +import { provide, shallowRef, ref } from 'vue'; import XColumn from './column.vue'; import { deckStore, Column } from '@/ui/deck/deck-store.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { useScrollPositionManager } from '@/nirax.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { mainRouter } from '@/router/main.js'; defineProps<{ column: Column; @@ -35,12 +35,14 @@ defineProps<{ }>(); const contents = shallowRef<HTMLElement>(); -const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const pageMetadata = ref<null | PageMetadata>(null); provide('router', mainRouter); -provideMetadataReceiver((info) => { +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); pageMetadata.value = info; }); +provideReactiveMetadata(pageMetadata); /* function back() { diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index 7df07fd8d7..70ec98119a 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 6a28bab091..837953b1e8 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -40,7 +40,7 @@ function func() { } const menu = [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.notificationSetting, action: func, }]; diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 5fbd1389b7..1a673a1753 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -19,6 +19,7 @@ import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -35,7 +36,7 @@ onMounted(() => { }); async function setRole() { - const roles = (await os.api('roles/list')).filter(x => x.isExplorable); + const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable); const { canceled, result: role } = await os.select({ title: i18n.ts.role, items: roles.map(x => ({ @@ -50,7 +51,7 @@ async function setRole() { } const menu = [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.role, action: setRole, }]; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index f6167b08f9..3745d026e8 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -114,7 +114,7 @@ async function setType() { } const menu = [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.timeline, action: setType, }, { diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue index d111f92443..92d1a673f6 100644 --- a/packages/frontend/src/ui/deck/widgets-column.vue +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -1,11 +1,11 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <XColumn :menu="menu" :naked="true" :column="column" :isStacked="isStacked"> - <template #header><i class="ph-squares-four ph-bold ph-lg" style="margin-right: 8px;"></i>{{ column.name }}</template> + <template #header><i class="ph-stack ph-bold ph-lg" style="margin-right: 8px;"></i>{{ column.name }}</template> <div :class="$style.root"> <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" :class="$style.intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> @@ -49,7 +49,7 @@ function func() { } const menu = [{ - icon: 'ph-pencil ph-bold ph-lg', + icon: 'ph-pencil-simple ph-bold ph-lg', text: i18n.ts.editWidgets, action: func, }]; diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue index f32f2de3df..db5eb19c20 100644 --- a/packages/frontend/src/ui/minimum.vue +++ b/packages/frontend/src/ui/minimum.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,21 +14,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { provide, ComputedRef, ref } from 'vue'; +import { computed, provide, ref } from 'vue'; import XCommon from './_common_/common.vue'; -import { mainRouter } from '@/router.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { instanceName } from '@/config.js'; +import { mainRouter } from '@/router/main.js'; -const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); + +const pageMetadata = ref<null | PageMetadata>(null); provide('router', mainRouter); -provideMetadataReceiver((info) => { +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); pageMetadata.value = info; - if (pageMetadata.value.value) { - document.title = `${pageMetadata.value.value.title} | ${instanceName}`; + if (pageMetadata.value) { + if (isRoot.value && pageMetadata.value.title === instanceName) { + document.title = pageMetadata.value.title; + } else { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } } }); +provideReactiveMetadata(pageMetadata); document.documentElement.style.overflowY = 'scroll'; </script> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 1d8e26bfcc..3a48c5eab9 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,19 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only <XWidgets/> </div> - <button v-if="(!isDesktop || pageMetadata?.needWideArea) && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ph-squares-four ph-bold ph-lg"></i></button> + <button v-if="(!isDesktop || pageMetadata?.needWideArea) && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ph-stack ph-bold ph-lg"></i></button> <div v-if="isMobile" ref="navFooter" :class="$style.nav"> <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ph-list ph-bold ph-lg-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ph-house ph-bold ph-lg"></i></button> + <button :class="$style.navButton" class="_button" @click="isRoot ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ph-house ph-bold ph-lg"></i></button> <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> <i :class="$style.navButtonIcon" class="ph-bell ph-bold ph-lg"></i> <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator"> <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> </span> </button> - <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ph-squares-four ph-bold ph-lg"></i></button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ph-pencil ph-bold ph-lg"></i></button> + <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ph-stack ph-bold ph-lg"></i></button> + <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ph-pencil-simple ph-bold ph-lg"></i></button> </div> <Transition @@ -105,18 +105,20 @@ import { defaultStore } from '@/store.js'; import { navbarItemDef } from '@/navbar.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { mainRouter } from '@/router.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; import { CURRENT_STICKY_BOTTOM } from '@/const.js'; import { useScrollPositionManager } from '@/nirax.js'; +import { mainRouter } from '@/router/main.js'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); +const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); + const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; @@ -127,18 +129,24 @@ window.addEventListener('resize', () => { isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD; }); -const pageMetadata = ref<null | PageMetadata>(); +const pageMetadata = ref<null | PageMetadata>(null); const widgetsShowing = ref(false); const navFooter = shallowRef<HTMLElement>(); const contents = shallowRef<InstanceType<typeof MkStickyContainer>>(); provide('router', mainRouter); -provideMetadataReceiver((info) => { - pageMetadata.value = info.value; +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); + pageMetadata.value = info; if (pageMetadata.value) { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + if (isRoot.value && pageMetadata.value.title === instanceName) { + document.title = pageMetadata.value.title; + } else { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } } }); +provideReactiveMetadata(pageMetadata); const menuIndicated = computed(() => { for (const def in navbarItemDef) { @@ -406,20 +414,20 @@ $widgets-hide-threshold: 1090px; .navButton { position: relative; padding: 0; - aspect-ratio: 1; + height: 32px; width: 100%; max-width: 60px; margin: auto; - border-radius: var(--radius-full); - background: var(--panel); + border-radius: var(--radius-lg); + background: transparent; color: var(--fg); &:hover { - background: var(--panelHighlight); + color: var(--accent); } &:active { - background: var(--X2); + color: var(--accent); } } @@ -430,15 +438,17 @@ $widgets-hide-threshold: 1090px; &:hover { background: linear-gradient(90deg, var(--X8), var(--X8)); + color: var(--fgOnAccent); } &:active { background: linear-gradient(90deg, var(--X8), var(--X8)); + color: var(--fgOnAccent); } } .navButtonIcon { - font-size: 18px; + font-size: 16px; vertical-align: middle; } @@ -448,7 +458,7 @@ $widgets-hide-threshold: 1090px; left: 0; color: var(--indicator); font-size: 16px; - animation: blink 1s infinite; + animation: global-blink 1s infinite; &:has(.itemIndicateValueIcon) { animation: none; diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue index 57d6ae0330..7e41328403 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XWidgets :edit="editMode" :widgets="widgets" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="editMode = false"/> <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.editWidgetsExit }}</button> - <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ph-pencil ph-bold ph-lg"></i> {{ i18n.ts.editWidgets }}</button> + <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ph-pencil-simple ph-bold ph-lg"></i> {{ i18n.ts.editWidgets }}</button> </div> </template> diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue index 78d7e23689..f76372ae34 100644 --- a/packages/frontend/src/ui/visitor.vue +++ b/packages/frontend/src/ui/visitor.vue @@ -1,11 +1,11 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div class="mk-app"> - <div v-if="!narrow && !root" class="side"> + <div v-if="!narrow && !isRoot" class="side"> <div class="banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div> <div class="dashboard"> <MkVisitorDashboard/> @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="main"> - <div v-if="!root" class="header"> + <div v-if="!isRoot" class="header"> <div v-if="narrow === false" class="wide"> <MkA to="/" class="link" activeClass="active"><i class="ph-house ph-bold ph-lg icon"></i> {{ i18n.ts.home }}</MkA> <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ph-chat-text ph-bold ph-lg icon"></i> {{ i18n.ts.timeline }}</MkA> @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div class="contents"> - <main v-if="!root" style="container-type: inline-size;"> + <main v-if="!isRoot" style="container-type: inline-size;"> <RouterView/> </main> <main v-else> @@ -67,31 +67,40 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ComputedRef, onMounted, provide, ref, computed } from 'vue'; +import { onMounted, provide, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import XCommon from './_common_/common.vue'; import { instanceName } from '@/config.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { instance } from '@/instance.js'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { mainRouter } from '@/router.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; +import { mainRouter } from '@/router/main.js'; + +const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); const DESKTOP_THRESHOLD = 1100; -const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const pageMetadata = ref<null | PageMetadata>(null); provide('router', mainRouter); -provideMetadataReceiver((info) => { +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); pageMetadata.value = info; - if (pageMetadata.value.value) { - document.title = `${pageMetadata.value.value.title} | ${instanceName}`; + if (pageMetadata.value) { + if (isRoot.value && pageMetadata.value.title === instanceName) { + document.title = pageMetadata.value.title; + } else { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } } }); +provideReactiveMetadata(pageMetadata); const announcements = { endpoint: 'announcements', @@ -117,9 +126,7 @@ const keymap = computed(() => { }; }); -const root = computed(() => mainRouter.currentRoute.value.name === 'index'); - -os.api('meta', { detail: true }).then(res => { +misskeyApi('meta', { detail: true }).then(res => { meta.value = res; }); diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 9f92f78764..77193a3475 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,24 +22,32 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { provide, ComputedRef, ref } from 'vue'; +import { computed, provide, ref } from 'vue'; import XCommon from './_common_/common.vue'; -import { mainRouter } from '@/router.js'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; +import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { instanceName, ui } from '@/config.js'; import { i18n } from '@/i18n.js'; +import { mainRouter } from '@/router/main.js'; -const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); + +const pageMetadata = ref<null | PageMetadata>(null); const showBottom = !(new URLSearchParams(location.search)).has('zen') && ui === 'deck'; provide('router', mainRouter); -provideMetadataReceiver((info) => { +provideMetadataReceiver((metadataGetter) => { + const info = metadataGetter(); pageMetadata.value = info; - if (pageMetadata.value.value) { - document.title = `${pageMetadata.value.value.title} | ${instanceName}`; + if (pageMetadata.value) { + if (isRoot.value && pageMetadata.value.title === instanceName) { + document.title = pageMetadata.value.title; + } else { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } } }); +provideReactiveMetadata(pageMetadata); function goToMisskey() { window.location.href = '/'; diff --git a/packages/frontend/src/unicode-emoji-indexes/ja-JP.json b/packages/frontend/src/unicode-emoji-indexes/ja-JP.json new file mode 100644 index 0000000000..9c491804f2 --- /dev/null +++ b/packages/frontend/src/unicode-emoji-indexes/ja-JP.json @@ -0,0 +1,1866 @@ +{ + "😀":["にやにやした顔","顔","にやにや","幸せ","しあわせ"], + "😃":["口を開けた笑顔","顔","口","開ける","笑顔","幸せ","しあわせ"], + "😄":["口を開けて目が笑っている笑顔","目","顔","口","開ける","笑顔","幸せ","しあわせ"], + "😁":["にやにやした顔","目","顔","にやにや","笑顔"], + "😆":["口を開けて笑っている顔","顔","笑い","口","開ける","満足","笑顔"], + "😅":["口を開けて冷や汗をかいた笑顔","ぞっとする","顔","口を開ける","笑顔","冷や汗"], + "😂":["嬉し泣き","顔","嬉しい","うれしい","笑う","泣く","涙"], + "🤣":["大爆笑","顔","床","笑い","大笑い","爆笑","ぐるぐる"], + "😇":["天使の笑顔","天使","顔","おとぎ話","ファンタジー","天使の輪","無邪気","笑顔"], + "😉":["ウインクした顔","顔","ウインク"], + "😊":["目が笑っている笑顔","赤面","目","顔","笑顔"], + "🙂":["微笑み","顔","笑顔","幸せ","しあわせ"], + "🙃":["逆さの顔","顔","逆さ","さかさ"], + "☺️":["笑顔","顔","輪郭","リラックス"], + "😋":["食べ物を味わう顔","美味しい","おいしい","顔","味わう","ふーむ","うまい"], + "😌":["ほっとした顔","顔","安心","ほっとする"], + "😍":["目がハートの笑顔","目","顔","ハート","愛","笑顔"], + "🥰":["笑顔とハート","顔","敬愛","べたぼれ","愛"], + "😘":["投げキッス","顔","ハート","キス"], + "😗":["キスをする顔","顔","キス"], + "😙":["笑顔でキス","目","顔","キス","笑顔"], + "😚":["目を閉じてキスをする顔","閉じた","目","顔","キス"], + "🥲":["涙の出ている笑顔","泣く","幸せ","感謝する","誇りに思う","安心する","笑う"], + "🤪":["おどけた顔","目","にやにや","変","興奮","ワイルド"], + "😜":["舌を出してウインクしている顔","目","顔","冗談","舌","ウインク"], + "😝":["舌を出して目を細めている顔","目","顔","怖い","恐い","こわい","味","舌"], + "😛":["舌を出している顔","顔","舌"], + "🤑":["強欲な顔","顔","お金","口"], + "😎":["サングラスをかけた顔","明るい","かっこいい","目","アイウエア","顔","眼鏡","メガネ","笑顔","太陽","サングラス","天気"], + "🤓":["オタク","顔","変な人"], + "🥸":["仮装した顔","仮装","メガネ","匿名の人","鼻"], + "🧐":["片メガネをかけた顔","退屈","裕福","豊か"], + "🤠":["カウボーイハットの顔","カウボーイ","カウガール","顔","帽子"], + "🥳":["パーティーフェイス","顔","祝典","帽子","角","パーティー"], + "🤡":["ピエロの顔","ピエロ","顔"], + "😏":["にやにやした顔","顔","にやにや"], + "😶":["口のない顔","顔","口","静かに","沈黙"], + "🫥":["点線の顔","落ち込んだ","消える","隠れる","内向的","目に見えない"], + "😐":["普通の顔","無表情","顔","平静"], + "🫤":["口が斜めになった顔","がっかり","無関心","疑い深い","不安"], + "😑":["無表情","顔","ポーカーフェイス","無感情"], + "😒":["面白くなさそうな顔","顔","つまらない","不幸"], + "🙄":["ぐるぐる目の顔","目","顔","ぐるぐる"], + "🤨":["眉が上がっている顔","不信","疑い深い","非難","疑念","やや驚き","懐疑的"], + "🤔":["考えている顔","顔","考え中"], + "🤫":["シッと言っている顔","シーッ","静か","黙る"], + "🤭":["口を手で覆った顔","目","笑顔","覆う","口","手"], + "🫢":["目を開いて口を手で覆った顔","驚嘆","畏敬","不信","狼狽","怖い","驚き"], + "🫡":["敬礼している顔","ok","敬礼","晴天","部隊","はい"], + "🤗":["両手を広げた笑顔","顔","ハグ","抱きしめる"], + "🫣":["のぞき見している顔","魅了","のぞき見","凝視","チラ見"], + "🤥":["嘘つき顔","顔","嘘","うそ","ピノキオ"], + "😳":["赤くなった顔","ぼーっとした","ぼうっとした","顔","赤面"], + "😞":["がっかりした顔","がっかり","顔"], + "😟":["不安な顔","顔","心配","不安"], + "😤":["勝ち誇った顔","顔","勝利","勝つ"], + "😠":["怒った顔","怒り","怒った","顔","激怒"], + "😡":["ふくれ顔","怒り","怒った","顔","激怒","ふくれっ面","ふくれっつら","憤怒","赤"], + "🤬":["口が記号で覆われた顔","呪い","ののしり","罵り"], + "😔":["悲しげな顔","がっかり","顔","悲しい"], + "😕":["困った顔","困った","こまった","顔"], + "🙁":["ご機嫌斜め","顔","しかめっ面","しかめっつら","悲しい","不幸"], + "☹":["しかめっつら","顔","しかめっ面","悲しい","不幸"], + "😬":["しかめっ面","顔","しかめっつら"], + "🥺":["訴えかける顔","顔","物乞い","慈悲","子犬の目"], + "😣":["我慢している顔","顔","がんばる","頑張る"], + "😖":["うろたえた顔","戸惑い","とまどい","うろたえ","顔"], + "😫":["疲れた顔","顔","疲れた","つかれた"], + "😩":["うんざりしている顔","顔","疲れた","つかれた","うんざり"], + "🥱":["あくびしている顔","飽きた","疲れた","あくび"], + "😪":["眠い顔","顔","寝る","睡眠"], + "😮💨":["ため息の出ている顔","顔","ため息","息切れ","うめき","安心","ささやき","口笛"], + "😮":["口を開けた笑顔","顔","口","開ける","同情"], + "😱":["絶叫した顔","顔","恐怖","怖い","恐い","こわい","ムンク","怯え","絶叫"], + "😨":["ゾッとしている顔","顔","恐怖","恐い","怖い","こわい","怯え"], + "😰":["口を開けて冷や汗をかいた顔","青ざめる","ぞっとする","顔","口","開ける","急ぐ","冷や汗"], + "😥":["がっかりしたが安心した顔","がっかり","顔","安心","ほっとする","やれやれ"], + "😓":["冷や汗をかいている顔","ぞっとする","顔","冷や汗"], + "😯":["落ち着いた顔","顔","黙る","呆然","驚き"], + "😦":["心配そうな顔の絵文字","顔","しかめっ面","しかめっつら","口","開ける"], + "😧":["苦悩に満ちた顔","苦悩","顔"], + "🥹":["涙をこらえている顔","怒る","泣く","誇りに思う","逆らう","悲しむ"], + "😢":["泣き顔","泣く","顔","悲しい","涙"], + "😭":["号泣","泣く","顔","悲しい","涙"], + "🤤":["よだれを垂らした顔","よだれ","顔"], + "🤩":["スターに夢中","目","顔","にやにや","星","夢想的"], + "😵":["目がバツになった顔","めまい","顔","バツ","目"], + "😵💫":["目がぐるぐるしている顔","めまい","顔","目","うっとり","ぐるぐる","トラブル","おー"], + "🥴":["ぼんやしりた顔","顔","目まい","酩酊","ほろ酔い","まっすぐでない目","波状の口"], + "😲":["驚いた顔","驚き","びっくり","顔","ショック","驚愕"], + "🫨":["震える顔","地震","顔","震え","衝撃","振動"], + "🤯":["爆発した頭","顔","ショック","爆発","狂気","びっくり"], + "🫠":["ほろりとした顔","消える","溶解する","液体","溶ける"], + "🤐":["お口チャック","顔","口","チャック"], + "😷":["マスクをした顔","風邪","かぜ","医者","顔","マスク","薬","病気"], + "🤕":["怪我","包帯","顔","傷","キズ","けが"], + "🤒":["温度計をくわえた顔","顔","病気","風邪","かぜ","体温計"], + "🤮":["吐きそうな顔","病気","嘔吐","風邪","かぜ","吐く"], + "🤢":["吐きそうな顔","顔","吐き気","嘔吐"], + "🤧":["くしゃみをする顔","顔","くしゃみ","ハクション"], + "🥵":["ほてった顔","顔","熱っぽい","熱射病","ほてった","赤ら顔","汗をかいた"], + "🥶":["青ざめた顔","顔","ぞっとする","凍える","凍傷","つらら"], + "😶🌫️":["雲で覆われた顔","顔","おっちょこちょい","非現実的","夢","もや","雲で覆われた頭"], + "😴":["寝顔","顔","寝る","睡眠","スヤスヤ"], + "💤":["睡眠","マンガ","漫画","寝る","スヤスヤ"], + "😈":["角つき笑顔","顔","おとぎ話","ファンタジー","角","笑顔"], + "👿":["小悪魔","鬼","悪魔","顔","おとぎ話","ファンタジー"], + "👹":["鬼","妖怪","顔","昔話","ファンタジー","日本","モンスター"], + "👺":["天狗","妖怪","顔","昔話","ファンタジー","日本","モンスター"], + "💩":["うんち","マンガ","漫画","フン","顔","モンスター"], + "👻":["お化け","妖怪","顔","おとぎ話","ファンタジー","幽霊","モンスター","ハロウィーン"], + "💀":["ドクロ","体","死","顔","おとぎ話","モンスター","骸骨","ハロウィーン"], + "☠":["ドクロマーク","体","交差した骨","死","顔","モンスター","骸骨","ハロウィーン"], + "👽":["宇宙人","怪獣","異星人","顔","おとぎ話","ファンタジー","モンスター","宇宙","UFO"], + "🤖":["ロボットの顔","顔","モンスター","ロボット"], + "🎃":["ジャック・オ・ランタン","イベント","お祝い","エンタメ","ハロウィン","ジャックオランタン","ランタン","かぼちゃ"], + "😺":["口を開けて笑う猫","猫","ネコ","顔","口","開ける","笑顔"], + "😸":["ニヤニヤ笑う猫","猫","ネコ","目","顔","ニヤニヤ","笑顔"], + "😹":["嬉し泣きしたネコの顔","猫","ネコ","顔","嬉しい","うれしい","涙"], + "😻":["ハートの目をした猫の笑顔","猫","ネコ","目","顔","ハート","愛","笑顔"], + "😼":["ニヤリと笑う猫の顔","猫","ネコ","顔","皮肉","笑顔","ニヤリ"], + "😽":["目を閉じてキスをする猫","猫","ネコ","目","顔","キス"], + "🙀":["疲れたネコの顔","猫","ネコ","顔","びっくり","驚く","うんざり"], + "😿":["泣いたネコの顔","猫","ネコ","泣く","顔","悲しい","涙"], + "😾":["怒ったネコの顔","猫","ネコ","顔","怒る","ふくれっ面","ふくれっつら"], + "🫶":["ハートポーズ","愛"], + "👐":["開いた手","体","手","広げる"], + "🤲":["上に向けた両手のひら","体","祈り","カップのように丸めた手"], + "🙌":["両手を上げる","体","お祝い","ジェスチャー","手","バンザイ","万歳","挙げる"], + "👏":["拍手","体","手を叩く","手"], + "🙏":["握った手","頼む","体","お辞儀","手を合わせる","ジェスチャー","手","お願い","祈る","ありがとう","感謝"], + "🤝":["握手","合意","手","手を結ぶ","会議"], + "👍":["イイね","体","上","手","指","サムズアップ","+1"], + "👎":["ダメ","体","下","手","指","サムズダウン","-1"], + "👊":["握りこぶし","体","握る","拳","こぶし","グー","手","パンチ","接近"], + "✊":["こぶし","体","握る","拳","グー","手","パンチ"], + "🤛":["左向きのこぶし","体","拳","左向き"], + "🤜":["右向きのこぶし","体","拳","右向き"], + "🤞":["交差させた指","体","交差","指","手","幸運"], + "✌":["Vサイン","体","手","V","ブイ","勝つ","勝利","ピース"], + "🫰":["人差し指と親指を交差した手","高い","ハート","愛","お金","スナップ"], + "🤘":["コルナ","体","指","手","角","最高"], + "🤟":["愛してるのジェスチャー","体","愛してる","好き","手"], + "👌":["OKサイン","体","手","OK"], + "🤌":["つまんでいる指","指","手ぶり","尋問","つまむ","皮肉"], + "🤏":["つまんでいる手","体","手","小さい","小型","ちっちゃい"], + "👈":["左指差し","手の甲","体","指","手","人差し指","指さす"], + "🫳":["手のひらを下にした手","退ける","落とす","シッシ"], + "🫴":["手のひらを上にした手","手招き","捕獲","来る","申し出"], + "👉":["指差し","手の甲","体","指","手","人差し指","指さす"], + "👆":["指差し","手の甲","体","指","手","人差し指","指さす","上"], + "👇":["指差し","手の甲","体","下","指","手","人差し指","指さす"], + "☝":["指差し","体","指","手","人差し指","指さす","上"], + "✋":["挙手","体","手"], + "🤚":["手の甲","体","挙げる"], + "🖐":["広げた手のひら","体","指","手","広げる"], + "🖖":["長寿と繁栄を","体","指","手","スポック","バルカン"], + "👋":["バイバイ","体","手","振る","やっほー","ヤッホー","こんにちは"], + "🤙":["電話の形の手","体","電話","手"], + "🫲":["左手","手","左","ひだり"], + "🫱":["右手","手","右","みぎ"], + "🫷":["左を押している手","辞退","ハイタッチ","左方向","押し付ける","断る","停止","待つ"], + "🫸":["右を押している手","辞退","ハイタッチ","押し付ける","断る","右方向","停止","待つ"], + "💪":["曲げた上腕二頭筋","力こぶ","体","マンガ","漫画","運動","筋肉","力","マッスル","マッチョ"], + "🦾":["メカニカルアーム","アクセシビリティ","義手","人口装具","体"], + "🖕":["中指を立てた手","体","指","手","中指"], + "🫵":["見ている人を指している人差し指","指す","あなた","指"], + "✍":["書いている手","体","手","書く"], + "🤳":["自撮り","カメラ","携帯","腕"], + "💅":["マニキュア","体","ケア","化粧品","コスメ","爪","ネイル"], + "🦵":["脚","体","キック","手足"], + "🦿":["機械の脚","アクセシビリティ","義足","人口装具","体"], + "🦶":["足","体","キック","踏みつける"], + "👄":["口","体","唇","クチビル"], + "🫦":["かんでいる唇","心配","怖い","浮気","神経質","不愉快","不安"], + "🦷":["歯","体","歯医者"], + "👅":["舌","体"], + "👂":["耳","体","鼻"], + "🦻":["補聴器を付けている耳","アクセシビリティ","補聴器","聞く","体","耳"], + "👃":["鼻","体"], + "👁":["目","体"], + "👀":["目","体","顔"], + "🧠":["脳","体","臓器","知的","賢い"], + "🫀":["解剖学的な心臓","解剖学","心臓学","心臓","臓器","脈"], + "🫁":["肺","息","呼気","吸入","臓器","呼吸"], + "🦴":["骨","体","骨格"], + "👤":["上半身のシルエット","上半身","シルエット"], + "👥":["上半身のシルエット","上半身","シルエット"], + "🗣":["喋る頭のシルエット","顔","頭","シルエット","しゃべる","話す"], + "🫂":["ハグしている人たち","さようなら","こんにちは","ハグ","ありがとう"], + "👶":["赤ちゃん"], + "👧":["女の子","少女","処女","おとめ座","星座","子供"], + "🧒":["子供","人","少年","少女"], + "👦":["男の子","少年","子供"], + "👩":["女性","女","おんな"], + "🧑":["成人向け","人","大人","男性","女性","女","男","おとこ","おんな"], + "👨":["男性","口ひげ","男","おとこ"], + "👩🦱":["女性,巻き毛","巻き毛","髪","女性","女","おんな"], + "🧑🦱":["人,巻き毛","巻き毛","髪"], + "👨🦱":["男性,巻き毛","巻き毛","髪","男性","男","おとこ"], + "👩🦰":["女性,赤毛","赤","髪","女性","女","おんな"], + "🧑🦰":["人,赤毛","赤","髪"], + "👨🦰":["男性,赤毛","赤","髪","男性","男","おとこ"], + "👱♀️":["女性,金髪","ブロンド","髪","女","おんな"], + "👱":["人,金髪","金髪","ブロンド","髪"], + "👱♂️":["男性,金髪","ブロンド","髪","男","男性","おとこ"], + "👩🦳":["女性,白髪","白","髪","女性","女","おんな"], + "🧑🦳":["人,白髪","白","髪"], + "👨🦳":["男性,白髪","白","髪","男性","男","おとこ"], + "👩🦲":["女性,禿","禿","女性","女","おんな"], + "🧑🦲":["人,禿","禿"], + "👨🦲":["男性,禿","禿","男性","男","おとこ"], + "🧔♀️":["ひげのある女性","あごひげ","ひげを生やした","女性","女","おんな"], + "🧔":["あごひげのある人","あごひげ","ひげを生やした"], + "🧔♂️":["ひげのある男性","あごひげ","ひげを生やした","男性","男","おとこ"], + "👵":["おばあさん","おばあちゃん","老人","女性","女","おんな"], + "🧓":["高齢者","人","男性","女性","女","男","おとこ","おんな"], + "👴":["おじいさん","おじいちゃん","老人","男","おとこ","男性"], + "👲":["スカルキャップをかぶっている人","中国帽","帽子"], + "👳♀️":["ターバンを巻いている女性","ターバン","女性","女","おんな"], + "👳":["ターバンを巻いている人","ターバン"], + "👳♂️":["ターバンを巻いている男性","ターバン","男","おとこ","男性"], + "🧕":["ヘッドスカーフをかぶった女性","ヘッドスカーフ","ヒジャブ","マンティラ","ティチェル","バンダナ","頭のスカーフ","女性","女","おんな"], + "👮♀️":["女性警察官","警察官","警官","警察","女性","女","おんな"], + "👮":["警察官","警官","警察"], + "👮♂️":["男性警察官","警察官","警官","警察","男","おとこ","男性"], + "👩🚒":["女性消防士","火","火事","消防","消防士","女性","女","おんな"], + "🧑🚒":["消防士","火事"], + "👨🚒":["男性消防士","火","火事","消防","消防士","男","おとこ","男性"], + "👷♀️":["女性の建設作業員","工事","建設","作業員","女性","女","おんな"], + "👷":["建設作業員","工事","建設","作業員"], + "👷♂️":["男性の建設作業員","建設","作業員","男性","男","おとこ"], + "👩🏭":["男性の工場作業員","工場","工業","作業員","女性","女","おんな"], + "🧑🏭":["工場作業員","工場","工業","溶接"], + "👨🏭":["男性の工場作業員","工場","工業","作業員","男","おとこ","男性"], + "👩🔧":["女性整備士","職人","配管工","電気技師","修理人","女性","女","おんな"], + "🧑🔧":["整備士","職人","配管工","電気技師","修理人"], + "👨🔧":["男性整備士","職人","配管工","電気技師","修理人","男","おとこ","男性"], + "👩🌾":["女性の農業従事者","農場労働者","牧場主","庭師","農家","女性","女","おんな"], + "🧑🌾":["農業従事者","農場労働者","牧場主","庭師","農家"], + "👨🌾":["男性の農業従事者","農場労働者","牧場主","庭師","農家","男","おとこ","男性"], + "👩🍳":["女性の料理人","食品","サービス","シェフ","コック","料理人","料理","女性","女","おんな"], + "🧑🍳":["料理人","食品","サービス","シェフ","コック","料理"], + "👨🍳":["男性の料理人","食品","サービス","シェフ","コック","料理人","料理","男","おとこ","男性"], + "👩🎤":["男性シンガー","音楽","ミュージシャン","ロック","ロッカー","ロックスター","芸能人","女性","女","おんな"], + "🧑🎤":["歌手","音楽","ミュージシャン","ロック","ロッカー","ロックスター","芸能人"], + "👨🎤":["男性シンガー","音楽","ミュージシャン","ロック","ロッカー","ロックスター","芸能人","男","おとこ","男性"], + "👩🎨":["女性アーティスト","芸術","アート","芸術家","アーティスト","絵画","画家","女性","女","おんな"], + "🧑🎨":["アーティスト","芸術","アート","芸術家","絵画","画家"], + "👨🎨":["男性アーティスト","芸術","アート","芸術家","アーティスト","絵画","画家","男","おとこ","男性"], + "👩🏫":["女性の教師","教育","先生","教授","教師","講師","女性","女","おんな"], + "🧑🏫":["教師","教育","先生","教授","講師"], + "👨🏫":["男性の教師","教育","先生","教授","教師","講師","男","おとこ","男性"], + "👩🎓":["女子生徒","学生","卒業生","教育","学校","女性","女","おんな"], + "🧑🎓":["生徒","学生","卒業生","教育","学校"], + "👨🎓":["男子生徒","学生","卒業生","教育","学校","男","おとこ","男性"], + "👩💼":["男性会社員","オフィス","会計士","銀行家","管理職","顧問","事務員","アナリスト","女性","女","おんな"], + "🧑💼":["会社員","オフィス","会計士","銀行家","管理職","顧問","事務員","アナリスト"], + "👨💼":["男性会社員","オフィス","会計士","銀行家","管理職","顧問","事務員","アナリスト","男","おとこ","男性"], + "👩💻":["女性技術者","テクノロジー","ソフトウェア","エンジニア","プログラマー","ラップトップ","ノートパソコン","女性","女","おんな"], + "🧑💻":["技術者","テクノロジー","ソフトウェア","エンジニア","プログラマー","ラップトップ","ノートパソコン"], + "👨💻":["男性技術者","テクノロジー","ソフトウェア","エンジニア","プログラマー","ラップトップ","ノートパソコン","男","おとこ","男性"], + "👩🔬":["女性科学者","科学者","化学者","技術者","数学者","物理学者","生物学者","検査技師","女性","女","おんな"], + "🧑🔬":["科学者","化学者","技術者","数学者","物理学者","生物学者","検査技師"], + "👨🔬":["男性科学者","科学者","化学者","技術者","数学者","物理学者","生物学者","検査技師","男","おとこ","男性"], + "👩🚀":["女性宇宙飛行士","宇宙","星","月","惑星","女性","女","おんな"], + "🧑🚀":["宇宙飛行士","宇宙","星","月","惑星"], + "👨🚀":["男性宇宙飛行士","宇宙","星","月","惑星","男","おとこ","男性"], + "👩⚕️":["女性医療関係者","医師","内科医","医学博士","看護師","歯科医","医療専門家","療法士","女性","女","おんな"], + "🧑⚕️":["医療関係者","医師","内科医","医学博士","看護師","歯科医","医療専門家","療法士"], + "👨⚕️":["男性医療関係者","医師","内科医","医学博士","看護師","歯科医","医療専門家","療法士","男","おとこ","男性"], + "👩⚖️":["女性裁判官","裁判官","法廷","裁判所","法律","女性","女","おんな"], + "🧑⚖️":["裁判官","法廷","裁判所","法律"], + "👨⚖️":["男性裁判官","裁判官","法廷","裁判所","法律","男","おとこ","男性"], + "👩✈️":["女性パイロット","パイロット","飛行機","操縦士","航空","女性","女","おんな"], + "🧑✈️":["パイロット","飛行機","操縦士","航空"], + "👨✈️":["男性パイロット","パイロット","飛行機","操縦士","航空","男","おとこ","男性"], + "💂♀️":["女性警備員","警備員","警備","女性","女","おんな"], + "💂":["警備員","警備"], + "💂♂️":["男性警備員","警備員","警備","男","おとこ","男性"], + "🥷":["忍者","戦士","隠された","ステルス"], + "🕵️♀️":["女性の探偵","探偵","刑事","スパイ","女性","女","おんな"], + "🕵":["探偵","刑事","スパイ"], + "🕵️♂️":["男性の探偵","探偵","刑事","スパイ","男","おとこ","男性"], + "🤶":["ミセス・クロース","イベント","お祝い","クリスマス","母","サンタ","クロース","女性","女","おんな"], + "🧑🎄":["ミクスクロース","アクティビティ","お祝い","クリスマス","サンタ","クロース"], + "🎅":["サンタクロース","イベント","お祝い","クリスマス","父","サンタ","クロース","男","おとこ","男性"], + "👼":["天使の赤ちゃん","天使","赤ちゃん","顔","おとぎ話","ファンタジー"], + "👸":["お姫さま","おとぎ話","ファンタジー","女王","女性","女","おんな"], + "🫅":["王冠をかぶった人","おとぎ話","ファンタジー","国王","貴族","王","王族"], + "🤴":["王子様","おとぎ話","ファンタジー","王","男","おとこ","男性"], + "👰":["ベールを付けた女性","花嫁","ベール","結婚式","女性","女","おんな"], + "👰♀️":["ベールを付けた人","花嫁","ベール","結婚式"], + "👰♂️":["ベールを付けた男性","花嫁","ベール","ウェディング","男性","男","おとこ"], + "🤵♀️":["タキシードの女性","タキシード","ウェディング","女性","女","おんな"], + "🤵":["タキシードを着る人","花婿","タキシード","ウェディング"], + "🤵♂️":["タキシードの男性","花婿","タキシード","ウェディング","男性","男","おとこ"], + "🩷":["ピンクのハート","かわいい","ハート","好き","愛","ピンク"], + "🩵":["ライトブルーのハート","シアン","ハート","ライトブルー","コガモ"], + "🩶":["グレーのハート","グレー","ハート","シルバー","スレート"], + "🕴️♀️":["宙に浮いたスーツの女性","ビジネス","スーツ","女性","女","おんな"], + "🕴":["宙に浮いたスーツの人","ビジネス","スーツ"], + "🕴️♂️":["宙に浮いたスーツの男性","ビジネス","スーツ","男","おとこ","男性"], + "🦸♀️":["女性のスーパーヒーロー","空想","善","ヒロイン","超大国","女性","女","おんな"], + "🦸":["スーパーヒーロー","空想","善","ヒーロー","ヒロイン","超大国"], + "🦸♂️":["男性のスーパーヒーロー","空想","善","ヒーロー","超大国","男性","男","おとこ"], + "🦹♀️":["女性の悪党","空想","悪","犯罪","悪事","超大国","悪役","女性","女","おんな"], + "🦹":["悪党","空想","悪","犯罪","悪事","超大国","悪役"], + "🦹♂️":["男性の悪党","空想","悪","犯罪","悪事","超大国","悪役","男性","男","おとこ"], + "🧙♀️":["女性の魔法使い","空想","魔女","女の魔法使い","女性","女","おんな"], + "🧙":["魔法使い","空想","魔術師","男の魔法使い"], + "🧙♂️":["男性の魔法使い","空想","魔術師","男の魔法使い","男性","男","おとこ"], + "🧝♀️":["女性の小人","空想","小人","先のとがった耳","女性","女","おんな"], + "🧝":["小人","空想","先のとがった耳"], + "🧝♂️":["男性の小人","空想","小人","先のとがった耳","男性","男","おとこ"], + "🧚♀️":["女性の妖精","空想","ティターニア","ウィングス","女性","女","おんな"], + "🧚":["妖精","空想","ティターニア","ウィングス"], + "🧚♂️":["男性の妖精","空想","オベロン","小妖精","男性","男","おとこ"], + "🧞♀️":["女性の精霊","空想","精霊","女性","女","おんな"], + "🧞":["精霊","空想"], + "🧞♂️":["男性の精霊","空想","精霊","男性","男","おとこ"], + "🧜♀️":["女性の人魚","空想","女性","女","おんな"], + "🧜":["人魚","空想"], + "🧜♂️":["男性の人魚","空想","人魚","男性","男","おとこ"], + "🧌":["釣り","おとぎ話","ファンタジ","モンスター"], + "🧛♀️":["女性の吸血鬼","空想","アンデッド","女性","女","おんな"], + "🧛":["吸血鬼","空想","ドラキュラ","アンデッド"], + "🧛♂️":["男性の吸血鬼","空想","ドラキュラ","アンデッド","男性","男","おとこ"], + "🧟♀️":["女性のゾンビ","空想","アンデッド","女性","女","おんな"], + "🧟":["ゾンビ","空想","アンデッド"], + "🧟♂️":["男性のゾンビ","空想","アンデッド","男性","男","おとこ"], + "🙇♀️":["深くお辞儀する女性","謝罪","お辞儀","ジェスチャー","ごめんなさい","女性","女","おんな"], + "🙇":["深くお辞儀した人","謝罪","お辞儀","ジェスチャー","ごめんなさい"], + "🙇♂️":["深くお辞儀する男性","謝罪","お辞儀","ジェスチャー","ごめんなさい","男","おとこ","男性"], + "💁♀️":["案内する女性","手","助け","情報","ずうずうしい","女性","女","おんな"], + "💁":["案内する人","手","助け","情報","ずうずうしい","女性","女","おんな"], + "💁♂️":["案内する男性","手","助け","情報","ずうずうしい","男","おとこ","男性"], + "🙅♀️":["NGサインの女性","禁じる","ジェスチャー","手","だめ","ダメ","禁止","女性","女","おんな"], + "🙅":["NGサインの人","禁じる","ジェスチャー","手","だめ","ダメ","禁止"], + "🙅♂️":["NGサインの男性","禁じる","ジェスチャー","手","だめ","ダメ","禁止","男","おとこ","男性"], + "🙆♀️":["OKサインの女性","ジェスチャー","手","ok","女性","女","おんな"], + "🙆":["OKサインの人","ジェスチャー","手","OK"], + "🙆♂️":["OKサインの男性","ジェスチャー","手","ok","男","おとこ","男性"], + "🤷♀️":["肩をすくめる女性","疑い","無知","無関心","肩をすくめる","女性","女","おんな"], + "🤷":["肩をすくめる人","疑い","無知","無関心","肩をすくめる"], + "🤷♂️":["肩をすくめる男性","疑い","無知","無関心","肩をすくめる","男","おとこ","男性"], + "🙋♀️":["片手を上げて喜ぶ女性","ジェスチャー","手","幸せ","しあわせ","挙げる","女性","女","おんな"], + "🙋":["片手を上げて喜ぶ人","ジェスチャー","手","幸せ","しあわせ","挙げる"], + "🙋♂️":["片手を上げて喜ぶ男性","ジェスチャー","手","幸せ","しあわせ","挙げる","男","おとこ","男性"], + "🤦♀️":["顔を押さえる女性","不信","憤慨","顔","手のひら","女性","女","おんな"], + "🤦":["手のひらを顔に当てる人","不信","憤慨","顔","手のひら"], + "🤦♂️":["顔を押さえる男性","不信","憤慨","顔","手のひら","男","おとこ","男性"], + "🧏♀️":["耳が不自由な女性","アクセシビリティ","耳が不自由","女性","女","おんな"], + "🧏":["耳が不自由な人","アクセシビリティ","耳が不自由"], + "🧏♂️":["耳が不自由な男性","アクセシビリティ","耳が不自由","男性","男","おとこ"], + "🙎♀️":["ふくれっ面の女性","ジェスチャー","ふくれっ面","ふくれっつら","女性","女","おんな"], + "🙎":["怒った顔の人","ジェスチャー","ふくれっ面","ふくれっつら"], + "🙎♂️":["ふくれっ面の男性","ジェスチャー","ふくれっ面","ふくれっつら","男","おとこ","男性"], + "🙍♀️":["顔をしかめた女性","しかめ面","ジェスチャー","悲しい","女性","女","おんな"], + "🙍":["不満な顔の人","しかめ面","ジェスチャー","悲しい"], + "🙍♂️":["顔をしかめた男性","しかめ面","ジェスチャー","悲しい","男性","男","おとこ"], + "💇♀️":["髪を切られている女性","理髪師","美容師","美容","散髪","ヘアカット","美容院","女性","女","おんな"], + "💇":["髪を切られている人","理髪師","美容師","美容","散髪","ヘアカット","美容院"], + "💇♂️":["髪を切られている男性","理髪師","美容師","美容","散髪","ヘアカット","美容院","男","おとこ","男性"], + "💆♀️":["フェイスマッサージを受ける女性","マッサージ","サロン","女性","女","おんな"], + "💆":["フェイスマッサージを受ける人","マッサージ","サロン"], + "💆♂️":["フェイスマッサージを受ける男性","マッサージ","サロン","男","おとこ","男性"], + "🤰":["妊婦","妊娠","赤ちゃん","女性","女","おんな","腹","ふくれた","ふっくらした"], + "🫄":["妊娠した人","腹","ふくれた","ふっくらした","妊娠","赤ちゃん"], + "🫃":["妊娠している男性","腹","ふくれた","ふっくらした","妊娠","赤ちゃん","男性","男","おとこ"], + "🤱":["母乳","胸","赤ちゃん","赤ん坊","乳児","幼児","母","子供","保育","ミルク","女性","女","おんな"], + "👩🍼":["赤ちゃんにご飯をあげる女性","赤ちゃん","乳児","子供","授乳","ミルク","ボトル","女性","女","おんな"], + "🧑🍼":["赤ちゃんにご飯をあげる人","赤ちゃん","乳児","子供","授乳","ミルク","ボトル"], + "👨🍼":["赤ちゃんにご飯をあげる男性","赤ちゃん","乳児","子供","授乳","ミルク","ボトル","男性","男","おとこ"], + "🧎♀️":["膝立ちしている女性","膝","膝立ち","女性","女","おんな"], + "🧎":["膝立ちしている人","膝","膝立ち"], + "🧎♂️":["膝立ちしている男性","膝","膝立ち","男性","男","おとこ"], + "🧍♀️":["立っている女性","立つ","スタンディング","女性","女","おんな"], + "🧍":["立っている人","立ち","スタンディング"], + "🧍♂️":["立っている男性","立つ","スタンディング","男性","男","おとこ"], + "🚶♀️":["歩く女性","ハイキング","歩行者","歩く","ウォーキング","女性","女","おんな"], + "🚶":["歩く人","ハイキング","歩行者","歩く","ウォーキング"], + "🚶♂️":["歩く男性","ハイキング","歩行者","歩く","ウォーキング","男","おとこ","男性"], + "👩🦯":["白杖を持った女性","アクセシビリティ","目が不自由","女性","女","おんな"], + "🧑🦯":["白杖を持った人","アクセシビリティ","目が不自由"], + "👨🦯":["白杖を持った男性","アクセシビリティ","目が不自由","男性","男","おとこ"], + "🏃♀️":["走る女性","マラソン","ランナー","ランニング","女性","女","おんな"], + "🏃":["走る人","マラソン","ランナー","ランニング"], + "🏃♂️":["走る男性","マラソン","ランナー","ランニング","男","おとこ","男性"], + "👩🦼":["電動車いすに座っている女性","アクセシビリティ","車いす","女性","女","おんな"], + "🧑🦼":["電動車いすに座っている人","アクセシビリティ","車いす"], + "👨🦼":["電動車いすに座っている男性","アクセシビリティ","車いす","男性","男","おとこ"], + "👩🦽":["手動車いすに座っている女性","アクセシビリティ","車いす","女性","女","おんな"], + "🧑🦽":["手動車いすに座っている人","アクセシビリティ","車いす"], + "👨🦽":["手動車いすに座っている男性","アクセシビリティ","車いす","男性","男","おとこ"], + "💃":["女性ダンサー","ダンス","踊る","ダンサー","女性","女","おんな"], + "🕺":["男性ダンサー","ダンス","踊る","ダンサー","男","おとこ","男性"], + "👯♀️":["バニーガール","うさぎ耳","ダンサー","女性","女","おんな"], + "👯":["うさぎ耳の人","うさぎ耳","ダンサー"], + "👯♂️":["うさぎ耳の男性","うさぎ耳","ダンサー","男","おとこ","男性"], + "👫":["手をつないだ男女","カップル","手","つなぐ","男","女","男女","おとこ","おんな"], + "👭":["手をつないだ女性","カップル","手","つなぐ","女性","女","おんな","プライド","lgbt","レズビアン"], + "👬":["手をつないだ男性","カップル","手","つなぐ","男性","男","おとこ","プライド","lgbt","ゲイ"], + "🧑🤝🧑":["手をつないだ人たち","カップル","手","握る"], + "👩❤️👨":["ハートのカップル (女性、男性)","カップル","ハート","愛","恋愛","男","女","男女","おとこ","おんな"], + "👩❤️👩":["ハートのカップル (女性、女性)","カップル","ハート","愛","恋愛","女性","女","おんな","プライド","lgbt","レズビアン"], + "💑":["ハートのカップル","カップル","ハート","愛","恋愛","男","女","男女","おとこ","おんな"], + "👨❤️👨":["ハートのカップル (男性、男性)","カップル","ハート","愛","恋愛","男性","男","おとこ","プライド","lgbt","ゲイ"], + "👩❤️💋👨":["キス (女性、男性)","カップル","キス","ハート","愛","恋愛","男","女","男女","おとこ","おんな"], + "👩❤️💋👩":["キス (女性、女性)","カップル","キス","ハート","愛","恋愛","女性","女","おんな","プライド","lgbt","ゲイ"], + "💏":["キス","カップル","キス","ハート","愛","恋愛","男","女","男女","おとこ","おんな"], + "👨❤️💋👨":["キス (男性、男性)","カップル","キス","ハート","愛","恋愛","男性","男","おとこ","プライド","lgbt","ゲイ"], + "👪":["家族","父親","母親","男","女","男女","おとこ","おんな","男の子","こども"], + "👨👩👧":["家族 (男性、女性、女の子)","父親","母親","男","女","男女","おとこ","おんな","女の子","こども"], + "👨👩👧👦":["家族 (男性、女性、女の子、男の子)","父親","母親","男","女","男女","おとこ","おんな","男の子","女の子","こども"], + "👨👩👦👦":["家族 (男性、女性、男の子、男の子)","父親","母親","男","女","男女","おとこ","おんな","男の子","こども"], + "👨👩👧👧":["家族 (男性、女性、女の子、女の子)","父親","母親","男","女","男女","おとこ","おんな","女の子","こども"], + "👩👩👦":["家族 (女性、女性、男の子)","家族","母親","女性","女","おんな","男の子","子供","プライド","lgbt","レズビアン"], + "👩👩👧":["家族 (女性、女性、女の子)","家族","母親","女性","女","おんな","女の子","子供","プライド","lgbt","レズビアン"], + "👩👩👧👦":["家族 (女性、女性、女の子、男の子)","家族","母親","女性","女","おんな","男の子","女の子","子供","プライド","lgbt","レズビアン"], + "👩👩👦👦":["家族 (女性、女性、男の子、男の子)","家族","母親","女性","女","おんな","男の子","子供","プライド","lgbt","レズビアン"], + "👩👩👧👧":["家族 (女性、女性、女の子、女の子)","家族","母親","女性","女","おんな","女の子","子供","プライド","lgbt","レズビアン"], + "👨👨👦":["家族 (男性、男性、男の子)","家族","父親","男性","男","おとこ","男の子","子供","プライド","lgbt","ゲイ"], + "👨👨👧":["家族 (男性、男性、女の子)","家族","父親","男性","男","おとこ","女の子","子供","プライド","lgbt","ゲイ"], + "👨👨👧👦":["家族 (男性、男性、女の子、男の子)","家族","父親","男性","男","おとこ","男の子","女の子","子供","プライド","lgbt","ゲイ"], + "👨👨👦👦":["家族 (男性、男性、男の子、男の子)","家族","父親","男性","男","おとこ","男の子","子供","プライド","lgbt","ゲイ"], + "👨👨👧👧":["家族 (男性、男性、女の子、女の子)","家族","父親","男性","男","おとこ","女の子","子供","プライド","lgbt","ゲイ"], + "👩👦":["家族(女性、男の子)","家族","母親","女性","女","おんな","男の子","こども"], + "👩👧":["家族(女性、女の子)","家族","母親","女性","女","おんな","女の子","こども"], + "👩👧👦":["家族(女性、女の子、男の子)","家族","母親","女性","女","男性","女の子","男の子","こども"], + "👩👦👦":["家族(女性、男の子、男の子)","家族","母親","女性","女","おんな","男の子","こども"], + "👩👧👧":["家族(女性、女の子、女の子)","家族","母親","女性","女","おんな","女の子","こども"], + "👨👦":["家族(男性、男の子)","父親","男","おとこ","男性","男の子","こども"], + "👨👧":["家族(男性、女の子)","父親","男","男女","女の子","こども"], + "👨👧👦":["家族(男性、女の子、男の子)","父親","男","おとこ","男性","男の子","女の子","こども"], + "👨👦👦":["家族(男性、男の子、男の子)","父親","男","おとこ","男性","男の子","こども"], + "👨👧👧":["家族(男性、女の子、女の子)","父親","男","男女","女の子","こども"], + "👚":["レディースウェア","服","女性","おんな"], + "👕":["Tシャツ","服","シャツ"], + "🥼":["白衣","服","医者","実験","科学者"], + "🦺":["安全ベスト","緊急","安全","ベスト"], + "🧥":["コート","服","ジャケット"], + "👖":["ジーンズ","服","パンツ","ズボン"], + "👔":["ネクタイ","服"], + "👗":["ドレス","服"], + "👘":["着物","服","和服"], + "🥻":["サリー","服","ドレス"], + "🩱":["ワンピース","服","水着","スイミングウェア","水泳"], + "👙":["ビキニ","服","水泳"], + "🩲":["ブリーフ","服","水着","スイミングウェア","水泳","下着"], + "🩳":["ショーツ","服","水着","スイミングウェア","水泳","下着"], + "💄":["口紅","化粧品","コスメ","化粧","メイク"], + "💋":["キスマーク","ハート","キス","唇","クチビル","マーク","恋愛","ロマンス"], + "👣":["足あと","体","服","足跡","あしあと"], + "🧦":["靴下","服","ソックス","一組"], + "🩴":["ゴム製サンダル","ビーチ","サンダル","草履"], + "👠":["ハイヒール","服","ヒール","靴","女性","おんな"], + "👡":["レディースサンダル","服","サンダル","靴","女性","おんな"], + "👢":["レディースブーツ","ブーツ","服","靴","女性","おんな"], + "🥿":["レディースフラットシューズ","服","バレエフラット","スリッポン","スリッパ"], + "👞":["メンズシューズ","服","男性","おとこ","靴"], + "👟":["運動靴","運動","服","シューズ","スニーカー"], + "🩰":["バレエシューズ","服","シューズ","バレエ","ダンス"], + "🥾":["ハイキングブーツ","服","バックパック","ブーツ","キャンプ","ハイキング"], + "🧢":["キャップ","服","野球","ハット","帽子"], + "👒":["レディースハット","服","帽子","女性","おんな"], + "🎩":["シルクハット","アクティビティ","服","エンターテインメント","娯楽","帽子","トップス"], + "🎓":["卒業式の角帽","アクティビティ","帽子","お祝い","服","卒業","ハット"], + "👑":["冠","服","王冠","王","女王"], + "⛑":["白十字のヘルメット","救助","十字","顔","帽子","ヘルメット"], + "🪖":["軍隊のヘルメット","軍","ヘルメット","軍隊","軍人","兵士"], + "🎒":["ランドセル","アクティビティ","鞄","バッグ","学生鞄","学校"], + "👝":["ポーチ","鞄","バッグ","服"], + "👛":["財布","服","コイン"], + "👜":["ハンドバッグ","鞄","バッグ","服"], + "💼":["ブリーフケース"], + "👓":["眼鏡","服","目","メガネ","アイウェア"], + "🕶":["サングラス","暗い","目","眼鏡","メガネ"], + "🥽":["ゴーグル","服","目の保護","水泳","溶接"], + "🧣":["スカーフ","服","首"], + "🧤":["手袋","服","手"], + "💍":["指輪","ダイヤモンド","恋愛","ロマンス"], + "🌂":["閉じた傘","服","雨","傘","天気"], + "☂":["傘","服","雨","天気"], + "🐶":["イヌの顔","犬","イヌ","顔","ペット"], + "🐱":["ネコの顔","猫","ネコ","顔","ペット"], + "🐭":["ネズミの顔","顔","ネズミ"], + "🐹":["ハムスターの顔","顔","ハムスター","ペット"], + "🐰":["ウサギの顔","バニー","顔","ペット","ウサギ"], + "🐻":["クマの顔","熊","クマ","顔"], + "🧸":["テディベア","玩具","ビロード","ぬいぐるみ","おもちゃ"], + "🐼":["パンダの顔","顔","パンダ","熊"], + "🐻❄️":["シロクマ","顔","北極","熊","白"], + "🐨":["コアラ","熊","有袋類","オーストラリア"], + "🐯":["トラの顔","顔","虎","トラ"], + "🦁":["ライオンの顔","顔","しし座","ライオン","星座"], + "🐮":["ウシの顔","牛","ウシ","顔"], + "🐷":["ブタの顔","顔","豚","ブタ"], + "🐽":["ブタの鼻","顔","鼻","豚","ブタ"], + "🐸":["カエルの顔","顔","蛙","カエル"], + "🐵":["サルの顔","顔","猿","サル"], + "🙈":["見ざる","悪い","顔","禁じる","ジェスチャー","猿","サル","だめ","ダメ","禁止","見る"], + "🙉":["聞かざる","悪い","顔","禁じる","ジェスチャー","聞く","サル","ない","なし","禁止"], + "🙊":["言わざる","悪い","顔","禁じる","ジェスチャー","猿","サル","ない","なし","禁止","話す"], + "🐒":["サル","猿"], + "🦍":["ゴリラ"], + "🦧":["オランウータン","類人猿"], + "🐔":["ニワトリ"], + "🐧":["ペンギン"], + "🐦":["鳥"], + "🐦⬛":["黒い鳥","鳥","黒","カラス","ワタリガラス","ミヤマガラス"], + "🐤":["ヒヨコ","赤ちゃん","ひよこ"], + "🐣":["ひよこ","赤ちゃん","孵化"], + "🐥":["正面を向いたヒヨコ","赤ちゃん","ひよこ"], + "🐺":["オオカミの顔","顔","オオカミ"], + "🦊":["キツネの顔","顔","キツネ"], + "🦝":["アライグマ","顔","好奇心が強い","ずる賢い"], + "🐗":["イノシシ","豚"], + "🐴":["ウマの顔","顔","馬"], + "🦓":["シマウマ","顔"], + "🦒":["キリン","顔"], + "🦌":["シカ"], + "🫎":["ヘラジカ","動物","枝角","エルク","哺乳類"], + "🦘":["カンガルー","オーストラリア","ジャンプ","有袋類"], + "🦥":["怠惰","なまける","遅い"], + "🦦":["カワウソ","釣り","ふざける"], + "🦫":["ビーバー","ダム"], + "🦄":["ユニコーンの顔","顔","ユニコーン"], + "🐝":["ミツバチ","ハチ","昆虫"], + "🐛":["虫","昆虫"], + "🦋":["チョウ","蝶","昆虫","美しい"], + "🐌":["カタツムリ"], + "🪲":["甲虫","虫","昆虫"], + "🐞":["テントウムシ","カブトムシ","昆虫","てんとう虫"], + "🐜":["アリ","蟻","昆虫"], + "🦗":["クリケット","コオロギ","バッタ目","昆虫"], + "🪳":["ゴキブリ","昆虫","害虫"], + "🕷":["クモ","昆虫","蜘蛛"], + "🕸":["クモの巣","クモ","巣"], + "🦂":["サソリ","さそり座","さそり","星座"], + "🦟":["蚊","病気","熱","昆虫","マラリア","ウイルス"], + "🪰":["ハエ","害虫","昆虫","蛆虫"], + "🪱":["蠕虫","環形動物","ミミズ","寄生虫"], + "🦠":["微生物","アメーバ","バクテリア","ウイルス"], + "🐢":["カメ"], + "🐍":["ヘビ","運搬人","へびつかい座","蛇","星座"], + "🦎":["トカゲ","爬虫類"], + "🐙":["タコ","蛸"], + "🦑":["イカ","軟体動物","烏賊"], + "🪼":["クラゲ","焼く","無脊椎動物","ゼリー","海","痛い","刺毛"], + "🦞":["ロブスター","ビスク","爪","シーフード"], + "🦀":["カニ","かに座","蟹","星座"], + "🦐":["エビ","貝","小さい"], + "🦪":["カキ","真珠","ダイビング"], + "🐠":["熱帯魚","魚","熱帯"], + "🐟":["魚","うお座","星座"], + "🐡":["フグ","魚"], + "🐬":["イルカ","ひれ"], + "🦈":["サメ","魚"], + "🦭":["アザラシ","アシカ"], + "🐳":["潮吹きクジラ","顔","潮吹き","クジラ"], + "🐋":["クジラ"], + "🐊":["ワニ"], + "🐆":["ヒョウ"], + "🐅":["トラ","虎"], + "🐃":["スイギュウ","水牛","水"], + "🐂":["雄牛","牡牛","おうし座","星座"], + "🐄":["ウシ","牛"], + "🦬":["バイソン","バッファロー","群れ","ヴィセント"], + "🐪":["ヒトコブラクダ","ラクダ","こぶ"], + "🐫":["フタコブラクダ","フタコブ","ラクダ","こぶ"], + "🦙":["ラマ","アルパカ","グアナコ","ビクーニャ","ウール"], + "🐘":["ゾウ","象"], + "🦏":["サイ"], + "🦛":["カバ"], + "🦣":["マンモス","絶滅","大型","牙","毛に覆われた"], + "🐐":["ヤギ","やぎ座","星座"], + "🐏":["仔羊","おひつじ座","ヒツジ","星座"], + "🐑":["ヒツジ","雌羊"], + "🐎":["馬","競馬","レース"], + "🫏":["ロバ","動物","ブーロ","哺乳類","ラバ"], + "🐖":["ブタ","雌豚"], + "🦇":["コウモリ","吸血鬼"], + "🐓":["おんどり"], + "🦃":["七面鳥(鳥)","七面鳥","鳥"], + "🕊":["平和の鳩","鳥","鳩","飛行","平和"], + "🦅":["ワシ","鳥"], + "🦆":["アヒル","鳥"], + "🪿":["ガチョウ","鳥","家禽","警笛の音"], + "🦢":["白鳥","鳥","白鳥の雄","醜いアヒルの子"], + "🦉":["フクロウ","鳥","賢い"], + "🦩":["フラミンゴ","熱帯","鮮やか"], + "🦚":["オスのクジャク","鳥","メスのクジャク"], + "🦜":["オウム","鳥","海賊"], + "🦤":["ドードー","鳥","絶滅"], + "🪽":["羽","天使","航空","鳥","飛行","神話"], + "🪶":["羽毛","鳥","軽い","羽"], + "🐕":["イヌ","犬","ペット"], + "🦮":["盲導犬","アクセシビリティ","目が不自由","犬","ガイド"], + "🐕🦺":["介助犬","アクセシビリティ","支援","犬","サービス"], + "🐩":["プードル","イヌ","犬"], + "🐈":["ネコ","猫","ペット"], + "🐈⬛":["黒猫","黒","猫","ペット","ハロウィーン"], + "🐇":["ウサギ","バニー","ペット"], + "🐀":["ネズミ"], + "🐁":["ネズミ"], + "🐿":["シマリス"], + "🦨":["スカンク","悪臭","臭う"], + "🦡":["アナグマ","ラーテル","ねだる"], + "🦔":["ハリネズミ","顔"], + "🐾":["動物の足あと","足","跡"], + "🐉":["ドラゴン","おとぎ話"], + "🐲":["ドラゴンの顔","ドラゴン","顔","おとぎ話"], + "🦕":["竜脚類","ブラキオサウルス","ブロントサウルス","ディプロドクス","恐竜"], + "🦖":["ティラノサウルス","Tレックス","恐竜"], + "🌵":["サボテン","植物"], + "🎄":["クリスマスツリー","アクティビティ","お祝い","クリスマス","エンターテイメント","ツリー"], + "🌲":["常緑樹","常緑","植物","木"], + "🌳":["落葉樹","落葉性","植物","落葉","木"], + "🌴":["ヤシの木","ヤシ","植物","木"], + "🪴":["鉢植え","植物","観葉植物"], + "🌱":["苗木","植物","若い"], + "🌿":["ハーブ","葉","植物"], + "☘":["クローバー","植物"], + "🍀":["四つ葉のクローバー","4","クローバー","四","葉","植物"], + "🎍":["門松","アクティビティ","竹","お祝い","日本","松","植物"], + "🎋":["七夕","アクティビティ","旗","お祝い","エンターテイメント","日本","木"], + "🍃":["風になびく葉","吹く","はためく","葉","植物","風"], + "🍂":["落ち葉","落下","葉","植物"], + "🍁":["カエデの葉","落下","葉","カエデ","植物"], + "🌾":["稲穂","稲束","穂","植物","米"], + "🪺":["卵のある巣","巣作り","鳥の巣","卵"], + "🪹":["空の巣","巣作り","鳥の巣"], + "🌺":["ハイビスカス","花","植物"], + "🌻":["ヒマワリ","花","植物","太陽","ひまわり"], + "🌹":["バラ","花","植物"], + "🥀":["しおれた花","花","しおれた"], + "🌷":["チューリップ","花","植物"], + "🌼":["花","植物"], + "🌸":["桜","花","植物"], + "🪷":["ハス","仏教","花","ヒンドゥー教","インド","清浄","ベトナム"], + "🪻":["ヒアシンス","ブルーボンネット","花","ラベンダー","ルピナス","ノウルーズ","紫","キンギョソウ"], + "💐":["花束","花","植物","ロマンス"], + "🍄":["キノコ","植物"], + "🐚":["巻き貝","貝"], + "🪸":["サンゴ","大洋","礁"], + "🌎":["アメリカ大陸","アメリカ","地球","世界"], + "🌍":["ヨーロッパとアフリカ地域","アフリカ","地球","ヨーロッパ","世界"], + "🌏":["アジアとオーストラリア","アジア","オーストラリア","地球","世界"], + "🌕":["満月","月","宇宙","天気"], + "🌖":["寝待月","十三夜","月","宇宙","欠け","天気"], + "🌗":["下弦の月","月","弦","宇宙","天気"], + "🌘":["欠けていく三日月","三日月","月","宇宙","欠け","天気"], + "🌑":["新月","晦","月","宇宙","天気"], + "🌒":["満ちていく三日月","三日月","月","宇宙","上弦","天気"], + "🌓":["上弦の月","月","弦","宇宙","天気"], + "🌔":["十三夜月","十三夜","月","宇宙","上弦","天気"], + "🌙":["三日月","月","宇宙","天気"], + "🌚":["顔つき新月","顔","月","宇宙","天気"], + "🌝":["顔つき満月","明るい","顔","満ちた","月","宇宙","天気"], + "🌛":["顔つき上弦の月","顔","月","弦","宇宙","天気"], + "🌜":["顔がある下弦の月","顔","月","弦","宇宙","天気"], + "⭐":["中くらいの星","星"], + "🌟":["光る星","きらめき","赤い光","輝く","輝き","星"], + "💫":["くらくら","漫画","めまい","星"], + "✨":["キラキラ","エンターテイメント","輝き","星"], + "☄":["彗星","宇宙"], + "🪐":["環のある惑星","宇宙","惑星","土星"], + "🌞":["顔つき太陽","明るい","顔","宇宙","太陽","天気"], + "☀️":["太陽の光","明るい","光線","宇宙","太陽","晴天","天気"], + "🌤":["太陽と小さな雲","雲","太陽","天気"], + "⛅":["晴れ時々曇り","雲","太陽","天気"], + "🌥":["晴れのち曇り","雲","太陽","天気"], + "🌦":["晴れのち曇り時々雨","雲","雨","太陽","天気"], + "☁️":["雲","天気"], + "🌧":["雨雲","雲","雨","天気"], + "⛈":["雷雨","雲","雨","雷","天気"], + "🌩":["雷雲","雲","雷","天気"], + "⚡":["高電圧記号","危険","電気","雷","電圧","ビリビリ"], + "🔥":["炎","火","道具"], + "💥":["衝突マーク","どかーん","衝突","漫画"], + "❄️":["雪の結晶","冷たい","雪","天気"], + "🌨":["雪雲","雲","冷","雪","天気"], + "☃":["雪だるま","冷","雪","天気"], + "⛄":["雪だるま","冷","雪","天気"], + "🌬":["風が吹いている","風が吹く","雲","顔","天気","風"], + "💨":["ダッシュ","漫画","走る"], + "🌪":["竜巻雲","雲","竜巻","天気","旋風"], + "🌫":["霧","雲","天気"], + "🌈":["虹","雨","レインボー","天気","プライド","lgbt"], + "☔":["雨と傘","衣類","しずく","雨","傘","天気"], + "💧":["雫","ぞっとする","漫画","したたり","汗","天気"], + "💦":["汗マーク","漫画","濡れている","汗"], + "🌊":["波","海","水","天気"], + "🍏":["青りんご","リンゴ","フルーツ","果物","緑","植物"], + "🍎":["赤いリンゴ","リンゴ","フルーツ","果物","植物","赤"], + "🍐":["梨","フルーツ","果物","植物"], + "🍊":["みかん","フルーツ","果物","オレンジ","植物","赤橙色"], + "🍋":["レモン","柑橘類","フルーツ","果物","植物"], + "🍌":["バナナ","フルーツ","果物","植物"], + "🍉":["スイカ","フルーツ","果物","植物"], + "🍇":["ブドウ","フルーツ","果物","植物"], + "🍓":["イチゴ","ベリー","フルーツ","果物","植物"], + "🍈":["メロン","フルーツ","果物","植物"], + "🍒":["さくらんぼ","フルーツ","果物","植物"], + "🫐":["ブルーベリー","ベリー","ビルベリー","青","フルーツ"], + "🍑":["桃","フルーツ","果物","植物"], + "🥭":["マンゴー","熱帯","フルーツ"], + "🍍":["パイナップル","フルーツ","果物","植物"], + "🥥":["ココナッツ","フルーツ"], + "🥝":["キウイフルーツ","フルーツ","果物","キウイ"], + "🍅":["トマト","植物","野菜"], + "🥑":["アボカド","フルーツ","果物"], + "🫒":["オリーブ","フルーツ"], + "🍆":["ナス","茄子","植物","野菜"], + "🌶":["トウガラシ","辛い","コショウ","植物"], + "🫑":["ピーマン","唐辛子","コショウ","植物","野菜"], + "🥒":["キュウリ","ピクルス","野菜"], + "🥬":["葉っぱの緑","チンゲン菜","キャベツ","ケール","レタス"], + "🥦":["ブロッコリー","野菜"], + "🫛":["エンドウ豆のさや","豆","枝豆","マメ科","エンドウ豆","さや","野菜"], + "🧄":["にんにく","野菜","植物","香味料"], + "🧅":["玉ねぎ","野菜","植物","香味料"], + "🌽":["トウモロコシ","コーン","植物"], + "🥕":["ニンジン","野菜"], + "🥗":["グリーンサラダ","緑","サラダ"], + "🥔":["ジャガイモ","野菜"], + "🍠":["焼き芋","ジャガイモ","焼き","スイーツ"], + "🌰":["栗","植物"], + "🥜":["ピーナッツ","ナッツ","野菜"], + "🫘":["豆","食べ物","腎臓","マメ"], + "🍯":["ハニーポット","はちみつ","ポット","スイーツ"], + "🍞":["パン","ローフ"], + "🥐":["クロワッサン","パン","三日月","ロール","フレンチ"], + "🥖":["フランスパン","パン","フレンチ"], + "🫓":["フラットブレッド","アレパ","ラヴァシュ","ナン","ピタ"], + "🥨":["プレッツェル","ソフトプレッツェル","プレッツェルツイスト","パン"], + "🥯":["ベーグル","パン","クリームチーズ","ひと塗り"], + "🥞":["パンケーキ","クレープ","ホットケーキ"], + "🧇":["ワッフル","ホットケーキ"], + "🧀":["チーズ"], + "🍗":["ターキー","骨","ニワトリ","脚","家禽"], + "🍖":["骨付き肉","骨","肉"], + "🥩":["一切れの肉","肉","切り身","ラムチョップ","豚","ステーキ"], + "🍤":["エビフライ","フライ","エビ","小エビ","てんぷら"], + "🥚":["卵"], + "🍳":["料理","卵","フライパン","鍋"], + "🥓":["ベーコン","肉"], + "🍔":["ハンバーガー","バーガー"], + "🍟":["フライドポテト","フライド","ポテト"], + "🌭":["ホットドッグ","フランクフルトソーセージ","ホットドッグソーセージ","ソーセージ","ウィンナー","レッドホット"], + "🍕":["ピザ","チーズ","1枚"], + "🍝":["スパゲッティ","パスタ"], + "🥪":["サンドウィッチ","パン","野菜","チーズ","肉","デリ"], + "🌮":["タコス","メキシコ"], + "🌯":["ブリトー","メキシコ"], + "🫔":["タマーレ","タマーリ","メキシカン","包まれた"], + "🥙":["フラットブレッドサンド","ファラフェル","フラットブレッド","ジャイロ","ケバブ","詰め物"], + "🧆":["ファラフェル","ひよこ豆"], + "🍜":["どんぶり","麺","ラーメン","蒸し加熱","スープ"], + "🥘":["パエリア","キャセロール","鍋","浅い"], + "🍲":["なべ","鍋","シチュー"], + "🫕":["フォンデュ","チーズ","チョコレート","フォデュ","溶けた","ポット","スイス"], + "🥫":["缶詰","かんづめ","保存用食品"], + "🫙":["瓶","香辛料","容器","空","ソース","貯蔵"], + "🧂":["塩","香辛料","シェーカー"], + "🧈":["バター","乳製品"], + "🫚":["ショウガ","ビール","根","スパイス"], + "🍥":["なると","固形の食べ物","魚","練り物"], + "🍣":["寿司"], + "🍱":["弁当箱","弁当","箱"], + "🍛":["カレーライス","カレー","ご飯"], + "🍙":["おにぎり","日本","米"], + "🍚":["ごはん","料理","米"], + "🍘":["せんべい","米"], + "🥟":["餃子","ギョウザ"], + "🍢":["おでん","シーフード","串","スティック"], + "🍡":["団子","デザート","日本","串","スティック","スイーツ"], + "🍧":["かき氷","デザート","氷","スイーツ"], + "🍨":["アイスクリーム","クリーム","デザート","氷","スイーツ"], + "🍦":["ソフトクリーム","クリーム","デザート","氷","アイスクリーム","ソフト","スイーツ"], + "🍰":["ショートケーキ","ケーキ","デザート","ペイストリー","スライス","スイーツ"], + "🎂":["バースデーケーキ","誕生日","ケーキ","お祝い","デザート","ペイストリー","スイーツ"], + "🧁":["カップケーキ","ベーカリー","スイーツ","デザート","ペイストリー"], + "🥧":["パイ","デザート","スイーツ"], + "🍮":["カスタード","デザート","プリン","スイーツ"], + "🍭":["ペロペロキャンディー","キャンディ","デザート","ロリポップキャンディ","スイーツ"], + "🍬":["アメ","デザート","スイーツ"], + "🍫":["チョコレート","バー","デザート","スイーツ"], + "🍿":["ポップコーン"], + "🍩":["ドーナツ","デザート","スイーツ"], + "🍪":["クッキー","デザート","甘い"], + "🥠":["おみくじ入りクッキー","フォーチュンクッキー"], + "🥮":["月餅","秋","祭"], + "☕":["ホットドリンク","飲料","コーヒー","飲み物","温かい","蒸気","お茶"], + "🍵":["湯のみ","飲料","カップ","飲み物","お茶","湯飲み"], + "🫖":["ティーポット","ドリンク","ポット","ティー","ケトル"], + "🥣":["ボウルとスプーン","朝食","シリアル","お粥","オートミール","ポリッジ","食器"], + "🍼":["哺乳瓶","赤ちゃん","ボトル","ドリンク","ミルク"], + "🥤":["カップとストロー","ジュース","ソーダ","モルト","ソフトドリンク","水","食器"], + "🧋":["タピオカティー","バブル","ミルク","パール","ティー","ボバ","タピオカ","モミ"], + "🧃":["飲料ボックス","ジュース","飲料","ボックス","ドリンク","ストロー"], + "🧉":["マテ","ドリンク","ボンビリヤ","イエルバ"], + "🥛":["コップに入った牛乳","ドリンク","グラス","ミルク"], + "🫗":["流れ込む液体","飲み物","空","グラス","こぼれる"], + "🍺":["ビール","バー","飲む","マグカップ"], + "🍻":["乾杯","バー","ビール","カチン","飲み物","マグカップ"], + "🍷":["ワイングラス","バー","飲料","飲み物","グラス","ワイン"], + "🥂":["グラスで乾杯","祝う","カチン","飲み物","グラス"], + "🥃":["タンブラー","グラス","酒","ショット","ウイスキー","ウィスキー","バーボン"], + "🍸":["カクテルグラス","バー","カクテル","飲み物","グラス"], + "🍹":["トロピカルドリンク","バー","飲み物","トロピカル"], + "🍾":["瓶と飛び出す栓","バー","ボトル","シャンパン","シャンペン","シャンパーニュ","コルク","飲み物","飛び出す","パーティー"], + "🍶":["とっくりとおちょこ","バー","飲料","ボトル","カップ","飲み物","酒"], + "🧊":["角氷","氷","立方体","冷たい","氷山"], + "🥄":["スプーン","食器"], + "🍴":["フォークとナイフ","調理","フォーク","ナイフ","食器"], + "🍽":["フォークとナイフとプレート","調理","フォーク","ナイフ","プレート","食器"], + "🥢":["箸","はし"], + "🥡":["テイクアウトボックス","テイクアウト","容器","お持ち帰り"], + "⚽":["サッカーボール","ボール","サッカー"], + "🏀":["バスケットボール","ボール","バスケットリング"], + "🏈":["アメリカンフットボール","アメリカン","ボール","フットボール"], + "⚾":["野球","ボール"], + "🥎":["ソフトボール","ボール","試合","スポーツ"], + "🎾":["テニスボール","ボール","ラケット","テニス"], + "🏐":["バレーボール","ボール","試合"], + "🏉":["ラグビー","ボール","フットボール"], + "🎱":["ビリヤード","8","エイトボール","ボール","エイト","ゲーム"], + "🥏":["空飛ぶ円盤","ディスク","アルティメット","ゴルフ","試合","スポーツ","フリスビー"], + "🪃":["ブーメラン","オーストラリア","逆戻り","跳ね返り"], + "🏓":["卓球のラケットとボール","ボール","バット","試合","パドル","卓球"], + "🏸":["バドミントンのラケットとシャトル","バドミントン","バーディー","試合","ラケット","シャトル"], + "🥅":["ゴールネット","ゴール","ネット"], + "🏒":["アイスホッケーのスティックとパック","試合","ホッケー","氷","パック","スティック"], + "🏑":["フィールドホッケーのスティックとボール","ボール","フィールド","試合","ホッケー","スティック"], + "🏏":["クリケットのバットとボール","ボール","フィールド","クリケット","試合"], + "🥍":["ラクロス","ボール","スティック","試合","スポーツ"], + "🥌":["カーリングストーン","カーリング","ストーン"], + "⛳":["ゴルフのカップ","ピンフラッグ","ゴルフ","ホール"], + "🏹":["弓矢","射手","矢","弓","射手座","道具","星座"], + "🎣":["釣竿と魚","エンターテイメント","魚","棒"], + "🤿":["ダイビングマスク","ダイビング","スキューバ","シュノーケル"], + "🥊":["ボクシンググローブ","ボクシング","グローブ"], + "🥋":["道着","柔道","空手","武道","テコンドー","ユニフォーム"], + "⛸":["アイススケート","氷"], + "🎿":["スキーとスキーブーツ","スキー","雪"], + "🛷":["そり","ソリ","ルージュ","トボガン"], + "⛷":["スキー","雪"], + "🏂":["スノーボーダー","スキー","雪","スノーボード"], + "🏋️♀️":["ウエイトを持ち上げる女性","挙げ","重量","女性","女","おんな"], + "🏋":["ウエイトを持ち上げる人","挙げ","重量"], + "🏋️♂️":["ウエイトを持ち上げる男性","挙げ","重量","男","おとこ","男性"], + "🤺":["フェンシングをする人","剣士","剣術","剣"], + "🤼♀️":["レスリングをする女性","レスリング","レスリング選手","女性","女","おんな"], + "🤼":["レスリングをする人たち","レスリング","レスリング選手"], + "🤼♂️":["レスリングをする男性","レスリング","レスリング選手","男","おとこ","男性"], + "🤸♀️":["側転をする女性","側方転回","体操","女性","女","おんな"], + "🤸":["側転をする人","側方転回","体操"], + "🤸♂️":["側転をする男性","側方転回","体操","男","おとこ","男性"], + "⛹️♀️":["ボールをバウンドさせる女性","ボール","女性","女","おんな"], + "⛹":["ボールをバウンドさせる人","ボール"], + "⛹️♂️":["ボールをバウンドさせる男性","ボール","男","おとこ","男性"], + "🤾♀️":["ハンドボールをする女性","ボール","ハンドボール","女性","女","おんな"], + "🤾":["ハンドボールをする人","ボール","ハンドボール"], + "🤾♂️":["ハンドボールをする男性","ボール","ハンドボール","男","おとこ","男性"], + "🧗♀️":["クライミングしている女性","クライミング","ロック","女性","女","おんな"], + "🧗":["クライミングしている人","クライミング","ロック"], + "🧗♂️":["クライミングしている男性","クライミング","ロック","男性","男","おとこ"], + "🏌️♀️":["ゴルフをする女性","ボール","ゴルフ","ゴルファー","ゴルフする","女性","女","おんな"], + "🏌":["ゴルフをする人","ボール","ゴルフ","ゴルファー","ゴルフする"], + "🏌️♂️":["ゴルフをする男性","ボール","ゴルフ","ゴルファー","ゴルフする","男","おとこ","男性"], + "🧘♀️":["蓮華座の女性","瞑想","ヨガ","静穏","女性","女","おんな"], + "🧘":["蓮華座の人","瞑想","ヨガ","静穏"], + "🧘♂️":["蓮華座の男性","瞑想","ヨガ","静穏","男性","男","おとこ"], + "🧖♀️":["スチームルームにいる女性","サウナ","スチームルーム","ハマム","スチームバス","女性","女","おんな"], + "🧖":["スチームルームにいる人","サウナ","スチームルーム","ハマム","スチームバス"], + "🧖♂️":["スチームルームにいる男性","サウナ","スチームルーム","ハマム","スチームバス","男性","男","おとこ"], + "🏄♀️":["サーフィンをする女性","サーファー","サーフィン","波乗り","女性","女","おんな"], + "🏄":["サーフィンをする人","サーファー","サーフィン","波乗り"], + "🏄♂️":["サーフィンをする男性","サーファー","サーフィン","波乗り","男","おとこ","男性"], + "🏊♀️":["泳ぐ女性","泳ぐ","水泳","女性","女","おんな"], + "🏊":["水泳をする人","泳ぐ","水泳"], + "🏊♂️":["泳ぐ男性","泳ぐ","水泳","男","おとこ","男性"], + "🤽♀️":["水球をする女性","ポロ","水","水球","女性","女","おんな"], + "🤽":["水球をする人","ポロ","水","水球"], + "🤽♂️":["水球をする男性","ポロ","水","水球","男","おとこ","男性"], + "🚣♀️":["ボートを漕ぐ女性","ボート","漕ぎ船","乗り物","漕艇","女性","女","おんな"], + "🚣":["ボートをこぐ人","ボート","漕ぎ船","乗り物","漕艇"], + "🚣♂️":["ボートを漕ぐ男性","ボート","漕ぎ船","乗り物","漕艇","男","おとこ","男性"], + "🏇":["競馬","馬","騎手","競走馬"], + "🚴♀️":["自転車に乗る女性","自転車","自転車乗り","自転車に乗る人","サイクリスト","女性","女","おんな"], + "🚴":["自転車に乗る人","自転車","自転車乗り","サイクリスト"], + "🚴♂️":["自転車に乗る男性","自転車","自転車乗り","自転車に乗る人","サイクリスト","男","おとこ","男性"], + "🚵♀️":["マウンテンバイクに乗る女性","マウンテンバイクライダー","クロスバイク","自転車","自転車乗り","自転車に乗る人","サイクリスト","山","女性","女","おんな"], + "🚵":["マウンテンバイクに乗る人","マウンテンバイクライダー","クロスバイク","自転車","自転車乗り","自転車に乗る人","山"], + "🚵♂️":["マウンテンバイクに乗る男性","マウンテンバイクライダー","クロスバイク","自転車","自転車乗り","自転車に乗る人","サイクリスト","山","男","おとこ","男性"], + "🎽":["ランニングシャツと襷","ランニング","襷","シャツ"], + "🎖":["勲章","お祝い","メダル","軍事"], + "🏅":["スポーツのメダル","メダル"], + "🥇":["金メダル","1位","金","メダル","1","第1位"], + "🥈":["銀メダル","メダル","2位","銀","2","第2位"], + "🥉":["銅メダル","銅","メダル","3位","3","第3位"], + "🏆":["トロフィー","賞"], + "🏵":["バラ飾り","植物"], + "🎗":["リマインダーリボン","お祝い","リマインダー","リボン"], + "🎫":["きっぷ","アクティビティ","入場料","エンターテイメント","チケット"], + "🎟":["入場券","入場料","エンターテイメント","チケット"], + "🎪":["サーカス小屋","アクティビティ","サーカス","エンターテイメント","テント"], + "🤹♀️":["ジャグリングをする女性","天秤","ジャグリング","女性","女","おんな"], + "🤹":["ジャグリングをする人","バランス","ジャグリング"], + "🤹♂️":["ジャグリングをする男性","天秤","ジャグリング","男性","男","おとこ"], + "🎭":["舞台芸術","アクティビティ","芸術","エンターテイメント","仮面","舞台","シアター"], + "🎨":["絵の具パレット","アクティビティ","アート","エンターテイメント","美術館","絵画","パレット"], + "🎬":["カチンコ","アクティビティ","エンターテイメント","映画"], + "🎤":["マイク","アクティビティ","エンターテイメント","カラオケ","マイクロフォン"], + "🎧":["ヘッドホン","アクティビティ","イヤホン","エンターテイメント","ヘッドフォン"], + "🎼":["楽譜","アクティビティ","エンターテイメント","音楽"], + "🎹":["鍵盤","アクティビティ","エンターテイメント","楽器","キーボード","音楽","ピアノ"], + "🪗":["アコーディオン","コンサーティーナ","スクイーズボックス"], + "🥁":["ドラム","ドラムスティック","音楽"], + "🪘":["長いドラム","ビート","コンガ","ドラム","リズム","ジャンベ"], + "🪇":["マラカス","祝う","楽器","音楽","騒音","打楽器","ガタガタ","リズム","シェイク"], + "🎷":["サックス","アクティビティ","エンターテイメント","楽器","音楽","サクソフォーン"], + "🎺":["トランペット","アクティビティ","エンターテイメント","楽器","音楽"], + "🪈":["フルート","竹","横笛奏者","フルート奏者","音楽","パイプ","リコーダー","吹く","木管楽器"], + "🎸":["ギター","アクティビティ","エンターテイメント","楽器","音楽"], + "🪕":["バンジョー","アクティビティ","エンターテイメント","楽器","音楽"], + "🎻":["バイオリン","アクティビティ","エンターテイメント","楽器","音楽"], + "🎲":["サイコロ","さい","エンターテイメント","ゲーム"], + "🧩":["パズルのピース","手がかり","噛み合う","ピース","パズル","ジグソー"], + "♟️":["チェスのポーン","チェス","駒","ゲーム","捨て駒"], + "🎯":["的中","アクティビティ","ブル","ブルズアイ","ダーツ","エンターテイメント","目","試合","ヒット","標的"], + "🎳":["ボウリング","ボール","試合"], + "🪀":["ヨーヨー","おもちゃ","上下"], + "🪁":["凧","おもちゃ","飛ぶ","舞う"], + "🛝":["滑り台","遊園地","遊び"], + "🎮":["テレビゲーム","コントローラー","エンターテイメント","ゲーム","ビデオゲーム"], + "👾":["エイリアン","宇宙人","怪獣","異星人","顔","おとぎ話","ファンタジー","モンスター","宇宙","UFO"], + "🎰":["スロットマシン","アクティビティ","ゲーム","スロット"], + "🚗":["自動車","車","乗り物"], + "🚙":["キャンピングカー","レクリエーション","RV","乗り物"], + "🚕":["タクシー","乗り物"], + "🛺":["オートリキシャ","人力車","トゥクトゥク"], + "🚌":["バス","乗り物"], + "🚎":["トロリーバス","バス","路面電車","市街電車","乗り物"], + "🏎":["レーシングカー","車","競争"], + "🚓":["パトカー","車","パトロール","警察","乗り物"], + "🚑":["救急車","乗り物"], + "🚒":["消防車","エンジン","炎","トラック","乗り物"], + "🚐":["マイクロバス","バス","乗り物"], + "🛻":["ピックアップトラック","ピックアップ","トラック","乗り物"], + "🚚":["配達用トラック","配達","トラック","乗り物"], + "🚛":["トレーラー","大型トラック","セミ","トラック","乗り物"], + "🚜":["トラクター","乗り物"], + "🏍":["レースバイク","オートバイ","レース"], + "🛵":["スクーター","モーター"], + "🚲":["自転車","バイク","乗り物"], + "🦼":["電動車いす","アクセシビリティ","車いす"], + "🦽":["手動車いす","アクセシビリティ","車いす"], + "🛴":["キックボード","キック","スクーター"], + "🛹":["スケボー","スケート","ボード"], + "🛼":["ローラースケート","ローラー","スケート"], + "🛞":["車輪","円","タイヤ","回転"], + "🚨":["パトライト","車","光","警察","回転","乗り物","サイレン","警告"], + "🚔":["パトカー","車","対向車","警察","乗り物"], + "🚍":["バス","対向車","乗り物"], + "🚘":["対向車","自動車","車","乗り物"], + "🚖":["タクシー","対向車","乗り物"], + "🚡":["ロープウェイ","空中","ケーブル","車","ゴンドラ","トラムウェイ","乗り物"], + "🚠":["ロープウェイ","ケーブル","ゴンドラ","山","乗り物"], + "🚟":["高架鉄道","鉄道","乗り物"], + "🚃":["鉄道車両","車","電気","鉄道","列車","路面","トロリーバス","乗り物"], + "🚋":["路面電車","車","路面","トロリーバス","乗り物"], + "🚝":["モノレール","乗り物"], + "🚄":["新幹線","鉄道","高速","列車","乗り物"], + "🚅":["新幹線","弾丸","鉄道","高速","列車","乗り物"], + "🚈":["ライトレール","鉄道","乗り物"], + "🚞":["山岳鉄道","車","山","鉄道","乗り物"], + "🚂":["蒸気機関車","エンジン","機関車","鉄道","蒸気","列車","乗り物"], + "🚆":["電車","線路","乗り物"], + "🚇":["地下鉄","メトロ","乗り物"], + "🚊":["路面電車","トロリーバス","乗り物"], + "🚉":["駅","線路","電車","乗り物"], + "🚁":["ヘリコプター","乗り物"], + "🛩":["小型航空機","飛行機","乗り物"], + "✈️":["飛行機","乗り物"], + "🛫":["飛行機の離陸","飛行機","チェックイン","出発","乗り物"], + "🛬":["飛行機の着陸","飛行機","到着","着陸","乗り物"], + "🪂":["パラシュート","パラセール","スカイダイブ","ハンググライダー"], + "💺":["座席","椅子"], + "🛰":["サテライト","衛星","宇宙","乗り物"], + "🚀":["ロケット","宇宙","乗り物"], + "🛸":["空飛ぶ円盤","UFO","宇宙人","異星人","宇宙","空想"], + "🛶":["カヌー","ボート"], + "⛵":["ヨット","ボート","リゾート","海","乗り物"], + "🛥":["モーターボート","ボート","乗り物"], + "🚤":["スピードボート","ボート","乗り物"], + "⛴":["フェリー","ボート"], + "🛳":["旅客船","旅客","船","乗り物"], + "🚢":["船","乗り物"], + "🛟":["救命浮き輪","浮き輪","ライフジャケット","ライフセーバー","救助","安全"], + "⚓":["いかり","船","ツール"], + "⛽":["ガソリンスタンド","燃料","ガソリン","給油機","サービスステーション"], + "🚧":["工事中","工事用フェンス","建設工事"], + "🚏":["バス停","バス","停止"], + "🚦":["縦向きの信号機","信号機","信号","交通"], + "🚥":["横向きの信号機","信号機","信号","交通"], + "🛑":["一時停止標識","八角形","標識","停止"], + "🎡":["観覧車","アクティビティ","遊園地","エンターテイメント","フェリス"], + "🎢":["ジェットコースター","アクティビティ","遊園地","コースター","エンターテイメント","ローラー"], + "🎠":["メリーゴーランド","アクティビティ","メリーゴーラウンド","エンターテイメント","馬"], + "🏗":["建設中","建物","建設"], + "🌁":["霧","天気"], + "🗼":["東京タワー","東京","タワー"], + "🏭":["工場","建物"], + "⛲":["噴水"], + "🎑":["お月見","アクティビティ","お祝い","授賞式","エンターテイメント","月"], + "⛰":["山"], + "🏔":["雪山","寒い","山","雪"], + "🗻":["富士山","山"], + "🌋":["火山","噴火","山","気象"], + "🗾":["日本列島","日本","地図"], + "🏕":["キャンプ"], + "⛺":["テント","キャンプ"], + "🏞":["国立公園","公園"], + "🛣":["高速道路","ハイウェイ","道路"], + "🛤":["線路","鉄道","電車"], + "🌅":["日の出","朝","太陽","天候"], + "🌄":["山からの日の出","朝","山","太陽","日の出","天候"], + "🏜":["砂漠"], + "🏖":["ビーチと傘","ビーチ","傘","パラソル"], + "🏝":["無人島","砂漠","島"], + "🌇":["ビルに沈む夕陽","建物","夕暮れ","太陽","夕日","天気"], + "🌆":["夕暮れの街並み","建物","街","夕暮れ","日暮れ","風景","太陽","夕日","天気"], + "🏙":["街並み","建物","街"], + "🌃":["星空","夜","星","天気"], + "🌉":["夜の橋","橋","夜","天気"], + "🌌":["天の川","宇宙","天気"], + "🌠":["流れ星","アクティビティ","落下","流れる","宇宙","星"], + "🎇":["線香花火","アクティビティ","お祝い","エンターテイメント","花火","キラキラ"], + "🎆":["花火","アクティビティ","お祝い","エンターテイメント"], + "🛖":["小屋","家","扇形庫","パオ"], + "🏘":["家","建物"], + "🏰":["西洋の城","建物","城","ヨーロッパ"], + "🏯":["日本の城","建物","城","日本"], + "🏟":["スタジアム"], + "🗽":["自由の女神","自由","像"], + "🏠":["家","建物","自宅"], + "🏡":["庭付きの家","建物","庭","自宅","家"], + "🏚":["廃墟","建物","廃屋","家"], + "🏢":["オフィスビル","建物"], + "🏬":["デパート","建物","店"], + "🏣":["日本の郵便局","建物","日本","ポスト"], + "🏤":["ヨーロッパの郵便局","建物","ヨーロッパ","ポスト"], + "🏥":["病院","建物","医師","薬"], + "🏦":["銀行","建物"], + "🏨":["ホテル","建物"], + "🏪":["コンビニエンスストア","建物","コンビニエンス","ストア"], + "🏫":["学校","建物"], + "🏩":["ラブホテル","建物","ホテル","ラブ"], + "💒":["結婚式","アクティビティ","チャペル","ロマンス"], + "🏛":["歴史的な建物","建物","歴史的な"], + "⛪":["教会","建物","クリスチャン","十字架","宗教"], + "🕌":["モスク","イスラム","ムスリム","宗教"], + "🛕":["ヒンドゥー教寺院","ヒンドゥー教","寺院","宗教"], + "🕍":["シナゴーグ","ユダヤ人","ユダヤ教","宗教","会堂"], + "🕋":["カアバ","イスラム","ムスリム","宗教"], + "⛩":["神社","宗教","神道"], + "⌚":["腕時計","時計"], + "📱":["携帯電話","携帯","コミュニケーション","モバイル","電話"], + "📲":["着信中","矢印","通話","携帯","コミュニケーション","モバイル","携帯電話","受信","電話"], + "💻":["パソコン","ノートパソコン","コンピューター","パーソナル"], + "⌨":["キーボード","コンピューター"], + "🖥":["デスクトップパソコン","コンピューター","デスクトップ"], + "🖨":["プリンター","コンピューター"], + "🖱":["3ボタンマウス","3","ボタン","コンピューター","マウス","三"], + "🖲":["トラックボール","コンピューター"], + "🕹":["ジョイスティック","エンターテイメント","ゲーム","ビデオゲーム"], + "🗜":["圧縮","ツール","欠陥"], + "💽":["MD","パソコン","光ディスク","エンターテイメント","ミニディスク","光学"], + "💾":["フロッピーディスク","コンピューター","ディスク","フロッピー"], + "💿":["CDディスク","ブルーレイ","CD","コンピューター","ディスク","DVD","光学"], + "📀":["DVD","ブルーレイ","CD","コンピューター","ディスク","エンターテイメント","光学"], + "📼":["ビデオテープ","エンターテイメント","テープ","VHS","ビデオ","ビデオカセット"], + "📷":["カメラ","エンターテイメント","ビデオ"], + "📸":["フラッシュを焚いたカメラ","カメラ","フラッシュ","ビデオ"], + "📹":["ビデオカメラ","カメラ","エンターテイメント","ビデオ"], + "🎥":["ビデオカメラ","アクティビティ","カメラ","シネマ","エンターテイメント","映画"], + "📽":["映写機","シネマ","娯楽","フィルム","映画","プロジェクター","ビデオ"], + "🎞":["フィルムのフレーム","シネマ","エンターテイメント","フィルム","フレーム","映画"], + "📞":["受話器","コミュニケーション","電話","受信機"], + "☎️":["電話","携帯電話"], + "📟":["ポケットベル","コミュニケーション","ポケベル"], + "📠":["FAX","コミュニケーション; fAX"], + "📺":["テレビ","エンターテイメント","TV","ビデオ"], + "📻":["ラジオ","エンターテイメント","ビデオ"], + "🎙":["スタジオマイク","マイク","音楽","スタジオ"], + "🎚":["調節バー","調節","音楽","バー"], + "🎛":["コントロールノブ","コントロール","つまみ","音楽"], + "⏱":["ストップウォッチ","時計"], + "⏲":["タイマー時計","時計","タイマー"], + "⏰":["目覚まし時計","アラーム","時計"], + "🕰":["置き時計","時計"], + "⏳":["砂時計","砂","タイマー"], + "⌛":["砂時計","砂","タイマー"], + "🧮":["そろばん","計算","カウント","集計表","数学"], + "📡":["衛星アンテナ","アンテナ","コミュニケーション","パラボラアンテナ","衛星"], + "🔋":["電池","バッテリー","電子","高エネルギー"], + "🪫":["バッテリー残量少","バッテリー","電子","低エネルギー"], + "🔌":["コンセント","電気","プラグ"], + "💡":["電球","漫画","電気","ひらめき","光"], + "🔦":["懐中電灯","電気","光","道具","たいまつ"], + "🕯":["ろうそく","光"], + "🧯":["消火器","消火","火","消す"], + "🗑":["ごみ箱","ゴミ箱","ごみ","ゴミ","缶","ビン"], + "🛢":["ドラム缶","ドラム","オイル"], + "🛒":["ショッピングカート","カート","ショッピング","トロリー"], + "💸":["羽の生えたお札","銀行","紙幣","請求書","ドル","飛ぶ","お金","羽"], + "💵":["ドル札","銀行","紙幣","お札","通貨","ドル","お金"], + "💴":["円記号の入った小切手","銀行","紙幣","お札","通貨","お金","円"], + "💶":["ユーロ札","銀行","紙幣","お札","通貨","ユーロ","お金"], + "💷":["ポンド札","銀行","紙幣","お札","通貨","お金","ポンド"], + "💰":["ドル袋","バッグ","ドル","お金"], + "🪙":["コイン","金","金属","お金","銀","宝"], + "💳":["クレジットカード","銀行","カード","クレジット","お金"], + "🪪":["身分証明書","資格情報","ID","ライセンス","セキュリティ"], + "🧾":["領収書","会計","簿記","証拠","証明"], + "💎":["宝石","ダイアモンド","ジュエル","ロマンス"], + "⚖":["はかり","天秤","公正","てんびん座","物差し","道具","重量","星座"], + "🦯":["白杖","アクセシビリティ","目が不自由"], + "🧰":["道具箱","胸","整備士","工具"], + "🔧":["レンチ","道具"], + "🪛":["ドライバー","ねじ","工具"], + "🔨":["ハンマー","道具"], + "⚒":["ハンマーとつるはし","ハンマー","つるはし","道具"], + "🛠":["ハンマーとレンチ","ハンマー","道具","レンチ"], + "⛏":["つるはし","採掘","道具"], + "🪓":["斧","たたき切り","手斧","割る","木材","工具"], + "🪚":["木工用のこぎり","大工","材木","のこぎり","工具"], + "🔩":["ナットとボルト","ボルト","ナット","道具"], + "⚙":["歯車","ギア","道具"], + "⛓":["鎖"], + "🪝":["フック","わな","いかさま","ペテン","誘惑","フィッシング","ツール"], + "🪜":["はしご","登る","横木","段","工具"], + "🧱":["れんが","粘土","建設","モルタル","壁"], + "🪨":["ロック","岩","建造物","重い","固体","石"], + "🪵":["木材","建造物","丸太","材木","木"], + "🔫":["水鉄砲","水","ピストル","噴射器","銃"], + "🧨":["爆竹","ダイナマイト","火薬","花火"], + "💣":["爆弾"], + "🔪":["包丁","キッチンナイフ","調理","ナイフ"], + "🗡":["短剣","ナイフ"], + "⚔":["交差した剣","交差","剣"], + "🛡":["盾"], + "🚬":["喫煙マーク","アクティビティ","喫煙"], + "⚰":["棺","死"], + "🪦":["墓石","墓地","死","墓","墓場","ハロウィーン"], + "⚱":["骨壷","死","葬儀"], + "🏺":["アンフォラ","みずがめ座","料理","飲み物","水差し","道具","星座"], + "🔮":["水晶玉","玉","水晶","おとぎ話","ファンタジー","占い","道具"], + "🪄":["魔法の杖","魔法","棒","魔女","魔法使い"], + "📿":["数珠状の祈りの用具","数珠","衣類","ネックレス","祈り","宗教"], + "🧿":["ナザールのお守り","数珠玉","お守り","邪視","ナザール","護符"], + "🪬":["ハムサ","お守り","ファティマ","手","メアリー","ミリアム","保護"], + "💈":["理髪店の看板柱","理髪店","床屋","散髪","看板柱"], + "🧲":["磁石","アトラクション","馬蹄"], + "⚗":["蒸留器","化学","実験","道具"], + "🧪":["試験管","化学者","化学","実験","実験室","科学"], + "🧫":["ペトリ皿","バクテリア","生物学者","生物学","文化","実験室"], + "🧬":["DNA","生物学者","進化","遺伝子","遺伝子学","生命"], + "🔭":["望遠鏡","ツール"], + "🔬":["顕微鏡","ツール"], + "🕳":["穴"], + "🩻":["X線","骨","医師","医療","骨格"], + "💊":["薬","医師","ピル","病気"], + "💉":["注射器","医師","薬","注射針","注射","病気","道具","ワクチン"], + "🩸":["血1滴","医師","薬","血液","生理"], + "🩹":["ガーゼ付きばんそうこう","医師","薬","バンドエイド","包帯","ばんそうこう"], + "🩺":["聴診器","医師","薬","心臓"], + "🌡":["温度計","天気","温度"], + "🩼":["松葉杖","杖","障碍","怪我","移動補助","棒"], + "🏷":["ラベル","荷札"], + "🔖":["ブックマーク","しおり","印"], + "🚽":["トイレ"], + "🪠":["プランジャー","フォースカップ","配管工","吸引","トイレ"], + "🚿":["シャワー","水"], + "🛁":["バスタブ","風呂","浴槽"], + "🛀":["風呂","浴槽"], + "🪮":["ヘアピック","アフロ","くし","髪","ピック"], + "🪥":["歯ブラシ","バスルーム","ブラシ","きれい","歯医者","衛生","歯"], + "🪒":["カミソリ","鋭い","髭剃り"], + "🧴":["ローションボトル","ローション","保湿剤","シャンプー","日焼け止め"], + "🧻":["ペーパーロール","ペーパータオル","トイレットペーパー"], + "🧼":["せっけん","棒","水浴び","クリーニング","泡","せっけん入れ"], + "🫧":["バブル","げっぷ","きれい","せっけん","水中"], + "🧽":["スポンジ","吸収","クリーニング","多孔性"], + "🧹":["ほうき","クリーニング","掃除","魔女"], + "🧺":["バスケット","農業","ランドリー","ピクニック"], + "🪣":["バケツ","たる","手桶","大だる"], + "🔑":["鍵","錠","パスワード"], + "🗝":["古い鍵","かぎ","鍵","錠","古い"], + "🪤":["ネズミ捕り器","餌","ネズミ","齧歯動物","輪なわ","わな"], + "🛋":["ソファーとランプ","ソファー","ホテル","ランプ"], + "🪑":["椅子","座席","座る"], + "🛌":["宿泊施設","寝る","ホテル","睡眠","ベッド"], + "🛏":["ベッド","ホテル","睡眠"], + "🚪":["ドア","扉"], + "🪞":["鏡","反射","反射体","反射鏡"], + "🪟":["窓","枠","新鮮な空気","ガラス","開口部","透明","視界"], + "🧳":["手荷物","パッキング","旅行","スーツケース"], + "🛎":["卓上ベル","ベル","ホテル"], + "🖼":["額に入った写真","アート","額縁","美術館","絵画","写真"], + "🧭":["コンパス","磁石","ナビゲーション","オリエンテーリング"], + "🗺":["世界地図","地図","世界"], + "⛱":["立てられたパラソル","雨","晴れ","傘","天気"], + "🪭":["折り畳み扇子","冷却","遠慮がち","ダンス","ファン","フラッター","熱","熱い","内気","広がる"], + "🗿":["モヤイ像","モアイ像","顔","像"], + "🛍":["買い物袋","鞄","ホテル","買い物"], + "🎈":["風船","アクティビティ","お祝い","エンターテイメント"], + "🎏":["こいのぼり","アクティビティ","鯉","お祝い","エンターテイメント","旗","吹流し"], + "🎀":["リボン","お祝い"], + "🧧":["赤い封筒","ギフト","幸運","紅包","利是","お金"], + "🎁":["プレゼント","箱","お祝い","エンターテイメント","贈り物","包装"], + "🎊":["くす玉","アクティビティ","お祝い","紙吹雪","エンターテイメント"], + "🎉":["クラッカー","アクティビティ","お祝い","エンターテイメント","パーティー","ジャーン"], + "🪅":["ピニャータ","お祝い","パーティー","ピナータ"], + "🪩":["ミラーボール","ダンス","ディスコ","輝き","パーティー"], + "🪆":["入れ子人形","人形","入れ子","ロシア"], + "🎎":["ひな祭り","アクティビティ","お祝い","人形","エンターテイメント","祭り","日本"], + "🎐":["風鈴","アクティビティ","鐘","お祝い","エンターテイメント","風"], + "🏮":["居酒屋の提灯","赤ちょうちん","居酒屋","日本","提灯","灯り","赤"], + "🪔":["ディヤランプ","ディヤ","ランプ","オイル"], + "✉️":["封筒","Eメール","電子メール"], + "📩":["メール受信中","矢印","コミュニケーション","下","Eメール","電子メール","封筒","手紙","メール","送る","送信"], + "📨":["メール受信","コミュニケーション","Eメール","電子メール","封筒","受け取る","手紙","メール","受信"], + "📧":["Eメール","コミュニケーション","電子メール","手紙","メール"], + "💌":["ラブレター","ハート","手紙","愛","メール","ロマンス"], + "📮":["ポスト","コミュニケーション","メール","郵便受け"], + "📪":["旗が下がっていて閉じている状態の郵便受け","閉じる","コミュニケーション","旗","下がった","メール","ポスト","郵便受け"], + "📫":["旗が上がっていて閉じている状態の郵便受け","閉じる","コミュニケーション","旗","メール","郵便受け","ポスト"], + "📬":["旗が上がっていて開いている状態の郵便受け","コミュニケーション","旗","メール","ポスト","開ける","郵便受け"], + "📭":["旗が下がっていて開いている郵便受け","コミュニケーション","旗","下げ","メール","メールボックス","開ける","郵便受け"], + "📦":["荷物","箱","コミュニケーション","パッケージ","小包"], + "📯":["郵便ラッパ","コミュニケーション","エンターテイメント","角","ポスト","郵便"], + "📥":["受信トレイ","箱","コミュニケーション","手紙","メール","受信","トレイ"], + "📤":["送信トレイ","箱","コミュニケーション","手紙","メール","送信","トレイ"], + "📜":["巻物","紙"], + "📃":["原稿","カール","ドキュメント","ページ"], + "📑":["ブックマークタブ","ブックマーク","マーク","マーカー","タブ"], + "📊":["棒グラフ","バー","チャート","グラフ"], + "📈":["上昇するグラフ","上昇チャート","チャート","グラフ","成長","トレンド","上向き"], + "📉":["下降するグラフ","下降チャート","チャート","下","グラフ","トレンド"], + "📄":["文書","ページ"], + "📅":["カレンダー","日付"], + "📆":["日めくりカレンダー","カレンダー"], + "🗓":["リングカレンダー","カレンダー","パッド","らせん状"], + "📇":["名刺フォルダ","カード","索引","ローラデックス"], + "🗃":["カードファイル","箱","カード","ファイル"], + "🗳":["投票用紙と投票箱","投票用紙","箱","票","投票"], + "🗄":["ファイル収納庫","収納","ファイル"], + "📋":["クリップボード"], + "🗒":["リングノート","ノート","パッド","らせん状"], + "📁":["フォルダ","ファイル"], + "📂":["開いたフォルダ","ファイル","フォルダ","開いた"], + "🗂":["仕切りカード","カード","仕切り","索引"], + "🗞":["丸めた新聞","ニュース","新聞","紙","丸めた"], + "📰":["新聞","コミュニケーション","ニュース","紙"], + "🪧":["プラカード","デモ","柵","抗議","看板"], + "📓":["ノート"], + "📕":["閉じた本","本","閉じている"], + "📗":["緑色の本","本","緑"], + "📘":["青い本","青","本"], + "📙":["オレンジ色の本","本","オレンジ"], + "📔":["装飾カバーのノート","本","カバー","装飾","ノート"], + "📒":["帳簿","元帳","ノート"], + "📚":["書籍","本"], + "📖":["開いた本","本","開いた"], + "🔗":["リンク"], + "📎":["クリップ","ペーパークリップ"], + "🖇":["繋がったペーパークリップ","コミュニケーション","リンク","ペーパークリップ"], + "✂️":["ハサミ","はさみ","道具"], + "📐":["三角定規","定規","配置","三角"], + "📏":["定規","直定規"], + "📌":["画鋲","ピン"], + "📍":["画鋲","ピン"], + "🧷":["安全ピン","おむつ","パンクロック"], + "🪡":["縫い針","刺しゅう","裁縫","縫い目","縫合","仕立て"], + "🧵":["スレッド","縫い編み","裁縫","糸巻","糸","手工芸"], + "🧶":["糸","ボール","かぎ針編み","ニット","手工芸"], + "🪢":["結び目","ロープ","絡んだ","ひも","より糸","ねじれ"], + "🔐":["コインロッカー","閉まっている","鍵","施錠","防犯"], + "🔒":["鍵","閉じられた","施錠"], + "🔓":["解錠","施錠","開ける"], + "🔏":["錠前とペン","インク","錠","ペン先","ペン","プライバシー"], + "🖊":["左下向きのボールペン","ボールペン","コミュニケーション","ペン"], + "🖋":["左下向きの万年筆","コミュニケーション","万年筆","ペン"], + "✒️":["ペン先","ペン"], + "📝":["メモ","コミュニケーション","鉛筆"], + "✏️":["鉛筆"], + "🖍":["左下向きのクレヨン","コミュニケーション","クレヨン"], + "🖌":["左下向きのブラシ","コミュニケーション","ペイントブラシ","絵"], + "🔍":["左向き虫眼鏡","眼鏡","拡大","検索","ツール"], + "🔎":["右向き虫眼鏡","眼鏡","拡大","検索","ツール"], + "❤️":["赤色のハート","ハート"], + "🧡":["オレンジ色のハート","ハート","オレンジ色"], + "💛":["黄色のハート","ハート","黄色"], + "💚":["緑のハート","ハート","緑"], + "💙":["青のハート","ハート","青"], + "💜":["紫のハート","ハート","紫"], + "🤎":["茶色のハート","ハート","茶色"], + "🖤":["黒いハート","ハート","黒","悪","悪者"], + "🤍":["白のハート","ハート","白"], + "💔":["割れたハート","ハート","壊れる","破局"], + "❣":["ハートのビックリマーク","ハート","ビックリマーク","記号"], + "💕":["2つのハート","ハート","愛"], + "💞":["回転するハート","ハート","回転"], + "💓":["鼓動するハート","ハート","鼓動","ドキドキ"], + "💗":["光るハート","ハート","ワクワク","光る","鼓動","緊張"], + "💖":["きらめくハート","ハート","ワクワク","キラキラ"], + "💘":["射抜かれたハート","ハート","矢","キューピッド","ロマンス"], + "💝":["リボン付きのハート","ハート","リボン","バレンタイン"], + "❤️🔥":["燃えているハート","ハート","火","燃える","愛","熱情","神聖なハート"], + "❤️🩹":["手当しているハート","ハート","健康になる","改善している","手当している","回復している","病み上がり","元気"], + "💟":["ハートのデコレーション","ハート"], + "☮":["ピースマーク","平和"], + "✝":["ラテン十字","クリスチャン","十字架","宗教"], + "☪":["星と三日月","イスラム","ムスリム","宗教"], + "🕉":["オームマーク","ヒンドゥー教","オーム","宗教"], + "☸":["法輪","仏教徒","ダーマ","宗教"], + "✡":["ダビデの星","ダビデ","ユダヤ人","ユダヤ教","宗教","星"], + "🔯":["六芒星","占い","星"], + "🕎":["ハヌッキーヤー","燭台","メノーラー","宗教"], + "☯":["陰陽","宗教","道","道家","陽","陰"], + "☦":["八端十字架","クリスチャン","十字架","宗教"], + "🪯":["カンダ","宗教","シーク教徒"], + "🛐":["礼拝所","宗教","礼拝"], + "⛎":["へびつかい座","運搬人","蛇","ヘビ","星座"], + "♈":["おひつじ座","仔羊","星座"], + "♉":["おうし座","牡牛","雄牛","星座"], + "♊":["ふたご座","ふたご","星座"], + "♋":["ガン","かに座","カニ","蟹","星座"], + "♌":["しし座","ライオン","星座"], + "♍":["おとめ座","乙女","処女","星座"], + "♎":["てんびん座","天秤","公正","はかり","星座"], + "♏":["さそり座","さそり","サソリ","星座"], + "♐":["いて座","射手","射手座","星座"], + "♑":["やぎ座","ヤギ","星座"], + "♒":["みずがめ座","運搬人","水","星座"], + "♓":["うお座","魚","星座"], + "🆔":["四角囲みID","ID","識別"], + "⚛":["元素記号","無神論者","原子"], + "⚕️":["アスクレピオスの杖","健康","世話","医師","薬","杖","ヘビ"], + "☢":["放射能標識","放射能"], + "☣":["バイオハザード標識","生物災害"], + "📴":["携帯電話電源オフ","携帯","コミュニケーション","モバイル","オフ","携帯電話","電話"], + "📳":["マナーモード","携帯","コミュニケーション","モバイル","モード","携帯電話","電話","バイブレーション"], + "🈶":["四角囲み有","日本語","あり"], + "🈚":["四角囲み無","四角囲み否","日本語","なし"], + "🈸":["四角囲み申","四角囲み適","中国語","申請"], + "🈺":["四角囲み営","中国語","営業"], + "🈷️":["四角囲み月","日本語","月極"], + "✴️":["八稜星","星"], + "🆚":["四角囲みVS","対","VS"], + "🉑":["丸囲み許可","丸囲み可","中国語","可能"], + "💮":["白い花","花","たいへんよくできました"], + "🉐":["丸囲み得","日本語","得"], + "㊙️":["丸囲み秘","中国語","表意文字","秘"], + "㊗️":["丸囲み祝","中国語","おめでとう","しゅく"], + "🈴":["四角囲みの合","四角囲み合","中国語","合格","適合"], + "🈵":["四角囲み満","中国語","満室","満車","満タン"], + "🈹":["四角囲み割","四角囲みの割","日本語","割引"], + "🈲":["四角囲み禁","日本語","禁止"], + "🅰️":["黒四角囲みA","A","血液型"], + "🅱️":["黒四角囲みB","B","血液型"], + "🆎":["黒四角囲みAB","AB","血液型"], + "🆑":["四角囲みCL","CL"], + "🅾️":["黒四角囲みO","血液型","O"], + "🆘":["四角囲みSOS","ヘルプ","SOS"], + "⛔":["立入禁止","立ち入り","禁止","だめ","できない","禁じる","交通"], + "📛":["名札","バッジ","名前"], + "🚫":["進入禁止","立ち入り","禁止","だめ","できない","禁じる"], + "❌":["バツ印","キャンセル","記号","掛け算","乗算","x"], + "⭕":["太い大きな丸","丸","O"], + "💢":["怒りマーク","怒り","漫画","激怒"], + "♨️":["温泉","温かい","湧き出る","蒸気"], + "🚷":["歩行者立入禁止","禁止","だめ","ない","歩行者","禁じる"], + "🚯":["ポイ捨て禁止","禁止","ごみ","だめ","ない","禁止されている"], + "🚳":["自転車禁止","自転車","バイク","禁止","だめ","できない","禁じる","乗り物"], + "🚱":["飲用不可","非飲料水","飲料","禁止","だめ","ない","飲用","禁止されている","水"], + "🔞":["18歳未満禁止","18","年齢制限","十八","禁止","だめ","ない","禁止した","未成年者"], + "📵":["携帯電話禁止","携帯","通信","禁止","モバイル","だめ","できない","携帯電話","禁止されている","電話"], + "🚭":["禁煙","禁止","だめ","できない","禁止されている","喫煙"], + "❗":["赤いビックリマーク","ビックリ","マーク","記号"], + "❕":["白いビックリマーク","ビックリ","マーク","囲み","記号"], + "❓":["赤いはてなマーク","マーク","記号","はてな"], + "❔":["白いはてなマーク","マーク","囲み","記号","はてな"], + "‼️":["!!マーク","バンバン","ビックリ","マーク","記号"], + "⁉️":["!?","ビックリ","インテロバング","マーク","記号","はてな"], + "💯":["100点","100","フル","百","スコア"], + "🔅":["低輝度","明るさ","薄暗い","低"], + "🔆":["高輝度","明るい","明るさ"], + "🔱":["トライデント","いかり","エンブレム","船","工具"], + "⚜":["ユリの紋章"], + "〽️":["庵点","印","部分"], + "⚠️":["警告"], + "🚸":["交差点を渡る子供たち","子供","交差点","歩行者","交通"], + "🔰":["初心者マーク","初心者","マーク","緑","日本","若葉","道具","黄"], + "♻️":["リサイクルマーク","リサイクル"], + "🈯":["四角囲み指","日本語"], + "💹":["上昇トレンドのチャートと円記号","上昇中円チャート","銀行","チャート","通貨","グラフ","成長","市場","お金","上昇","トレンド","上向き","円"], + "❇️":["キラキラ"], + "✳️":["アスタリスク (8本構成)","アスタリスク"], + "❎":["四角で囲まれたバツ印","マーク","四角"], + "✅":["白い太字のチェックマーク","チェック","マーク"], + "💠":["ドット模様のダイヤ","漫画","ダイヤモンド","幾何学","内部"], + "🌀":["サイクロン","低気圧","めまい","竜巻","台風","天気"], + "➿":["二重のカール状のループ","カール","ダブル","ループ"], + "🌐":["子午線・経線のある地球","地球","地球儀","経線","世界"], + "♾":["無限","永遠","普遍的"], + "Ⓜ️":["丸囲みM","円","M"], + "🏧":["ATM","ATM記号","自動","銀行","出納"], + "🚾":["トイレ","化粧室","お手洗い","水","WC"], + "♿":["車いす","アクセス","車椅子"], + "🅿️":["黒四角囲みP","駐車場"], + "🈳":["四角囲み空","四角囲みの空","中国語","空室","空き","空車"], + "🈂️":["四角囲みサ","日本人","サービス"], + "🛂":["入国審査","パスポート"], + "🛃":["税関"], + "🛄":["手荷物受取所","手荷物","受け取り"], + "🛅":["手荷物預かり所","手荷物","ロッカー","携行品"], + "🚰":["飲料水","飲み物","水"], + "🛗":["エレベーター","アクセシビリティ","引き上げ","昇降機"], + "🚹":["男性の記号","男性用","トイレ","男","おとこ","男性"], + "♂️":["男性記号","男性","男","おとこ"], + "🚺":["女性の記号","女性用","トイレ","女","おんな","女性"], + "♀️":["女性記号","女性","女","おんな"], + "⚧️":["トランスジェンダーサイン","トランスジェンダー","プライド","lgbt"], + "🚼":["赤ちゃんマーク","赤ちゃん","おむつ替え"], + "🚻":["トイレ","化粧室","WC"], + "🚮":["ゴミ捨て場","ビンのゴミ捨て場","ゴミ","ゴミ箱"], + "🎦":["映画","アクティビティ","カメラ","エンターテイメント","フィルム","動画"], + "📶":["アンテナ","バー","携帯","コミュニケーション","モバイル","携帯電話","シグナル","電話"], + "🛜":["無線","コンピュータ","インターネット","ネットワーク","Wi-Fi","接続"], + "🈁":["四角囲みココ","日本人"], + "🆖":["四角囲みNG","NG"], + "🆗":["四角囲みOK","OK"], + "🆙":["四角囲みUP!","マーク","上"], + "🆒":["COOL","かっこいい","クール"], + "🆕":["四角囲みnew","新"], + "🆓":["四角囲みFREE","フリー","無料"], + "0️⃣":["0キー","0","キー","ゼロ"], + "1️⃣":["1キー","1","キー","一"], + "2️⃣":["2キー","2","キー","ニ"], + "3️⃣":["3キー","3","キー","三"], + "4️⃣":["4キー","4","四","キー"], + "5️⃣":["5キー","5","五","キー"], + "6️⃣":["6キー","6","キー","六"], + "7️⃣":["7キー","7","キー","七"], + "8️⃣":["8キー","8","八","キー"], + "9️⃣":["9キー","9","キー","九"], + "🔟":["10キー","10","キー","十"], + "🔢":["番号の入力記号","1234","入力","数字"], + "▶️":["右向き三角","再生ボタン","矢印","再生","右","三角形"], + "⏸":["2本の垂直バー","一時停止ボタン","バー","2倍","一時停止","垂直"], + "⏯":["右向きの三角形と二重垂直棒","再生または一時停止ボタン","矢印","一時停止","再生","右","三角形"], + "⏹":["停止","停止ボタン","四角"], + "⏺":["録画","録画ボタン","丸"], + "⏏️":["取り出しマーク","取り出しボタン"], + "⏭":["右向きの二重三角形と垂直棒","「次の曲」ボタン","矢印","次の場面","次の曲","三角形"], + "⏮":["左向きの二重三角形と垂直棒","「前の曲」ボタン","矢印","前の場面","前の曲","三角形"], + "⏩":["右向きの二重三角形","早送りボタン","矢印","2倍","高速","進む"], + "⏪":["左向きの二重三角形","早戻しボタン","矢印","2倍","巻き戻し"], + "🔀":["ねじり右向き矢印の絵文字","シャッフル","矢印","交差"], + "🔁":["リピート","リピートボタン","矢印","時計回り"], + "🔂":["1曲をリピート再生","リピートボタン","矢印","時計回り","一度"], + "◀️":["左向きの三角形","反転ボタン","矢印","左","反転","三角形"], + "🔼":["上向きの三角形","上ボタン","矢印","ボタン","上"], + "🔽":["下向きの三角形","下ボタン","矢印","ボタン","下"], + "⏫":["上向きの二重三角形","高速上昇ボタン","矢印","ダブル","上"], + "⏬":["下向きの二重三角形","高速ダウンボタン","矢印","ダブル","下"], + "➡️":["右向き矢印","右矢印","矢印","主要","方向","東"], + "⬅️":["左向き矢印","左矢印","矢印","主要","方向","西"], + "⬆️":["上向き矢印","上矢印","矢印","主要","方向","北"], + "⬇️":["下向き矢印","下矢印","矢印","主要","方向","下","南"], + "↗️":["右上矢印","矢印","方向","斜め","北東"], + "↘️":["右下矢印","矢印","方向","斜め","南東"], + "↙️":["左下矢印","矢印","方向","斜め","南西"], + "↖️":["左上矢印","矢印","方向","斜め","北西"], + "↕️":["上下矢印","矢印","方向","斜め","北西"], + "↔️":["左右矢印","矢印"], + "🔄":["うずまき矢印","反時計回り","矢印","左回り"], + "↪️":["右向き段付き矢印","右に曲がった矢印","矢印"], + "↩️":["左向き段付き矢印","左に曲がった矢印","矢印"], + "🔃":["ループ矢印","時計の針","矢印","時計回り","リロード"], + "⤴️":["右上へカーブする矢印","上へカーブする右矢印","矢印"], + "⤵️":["右下へカーブする矢印","下にカーブする右矢印","矢印","下"], + "#️⃣":["#キー","ハッシュ","キー","ポンド"], + "*⃣":["アスタリスクキー","アスタリスク","キー","星"], + "ℹ️":["情報源","i","インフォメーション"], + "🔤":["アルファベット入力","abc","アルファベット","入力","ラテン","文字"], + "🔡":["アルファベット小文字入力","abcd","入力","ラテン","文字","小文字"], + "🔠":["アルファベット大文字入力","入力","ラテン","文字","大文字"], + "🔣":["記号入力","入力"], + "🎵":["音符","アクティビティ","エンターテイメント","音楽"], + "🎶":["複数の音符","アクティビティ","エンターテイメント","音楽","音符"], + "〰️":["波線","ダッシュ","記号","波"], + "➰":["カール状のループ","カール","ループ"], + "✔️":["太字のチェックマーク","チェック","マーク"], + "➕":["太字の+記号","数学","プラス"], + "➖":["太字のマイナス記号","数学","マイナス"], + "➗":["太字の÷記号","割り算","数学"], + "✖️":["太字の×印","キャンセル","乗算","かける","x"], + "🟰":["太い等号","等式","数学","等しい"], + "💲":["太字のドル記号","通貨","ドル","お金"], + "💱":["外貨両替","銀行","通貨","両替","お金"], + "©️":["コピーライトマーク","著作権"], + "®️":["登録商標マーク","登録済み","商標"], + "™️":["商標マーク","マーク","tm","商標"], + "🔚":["ENDと左矢印","矢印","端"], + "🔙":["BACKと左矢印","矢印","戻る"], + "🔛":["ON!と左右矢印","矢印","マーク","オン"], + "🔝":["TOPと上矢印","矢印","トップ","上"], + "🔜":["SOONと右矢印","矢印","まもなく"], + "☑️":["チェック入りチェックボックス","投票","ボックス","チェック"], + "🔘":["ラジオボタン","ボタン","幾何学","ラジオ"], + "🔴":["赤丸","円","幾何学","赤"], + "🟠":["オレンジ色の円","円","幾何学","オレンジ"], + "🟡":["黄色の丸","円","幾何学","茶色"], + "🟢":["緑丸","円","幾何学","緑"], + "🔵":["青丸","青","円","幾何学"], + "🟣":["紫の丸","円","幾何学","紫"], + "🟤":["茶色の丸","円","幾何学","茶色"], + "⚫":["黒丸","円","幾何学"], + "⚪":["白丸","円","幾何学"], + "🟥":["赤の正方形","正方形","幾何学","赤"], + "🟧":["オレンジ色の正方形","正方形","幾何学","オレンジ"], + "🟨":["黄色の正方形","正方形","幾何学","黄色"], + "🟩":["緑の正方形","正方形","幾何学","緑"], + "🟦":["青の正方形","正方形","幾何学","青"], + "🟪":["紫の正方形","正方形","幾何学","紫"], + "🟫":["茶色の正方形","正方形","幾何学","茶色"], + "⬛":["黒い大きな四角","幾何学","正方形"], + "⬜":["白い大きな四角","幾何学","正方形"], + "◼️":["黒い中くらいの四角","幾何学","正方形"], + "◻️":["白くて中くらいの四角","幾何学","正方形"], + "◾":["黒くて中くらいの小さい四角","幾何学","正方形"], + "◽":["白い中くらいの小さな四角","幾何学","正方形"], + "▪️":["黒い小さな四角","幾何学","正方形"], + "▫️":["白い小さな四角","幾何学","正方形"], + "🔸":["小さいオレンジのダイヤモンド","ダイヤモンド","幾何学","オレンジ"], + "🔹":["小さくて青いダイヤモンド","青","ダイヤモンド","幾何学"], + "🔶":["大きいオレンジのダイヤ","ダイヤモンド","幾何学","オレンジ"], + "🔷":["大きくて青いダイヤモンド","青","ダイヤモンド","幾何学"], + "🔺":["上向きの赤い三角形","上","幾何学","赤"], + "🔻":["下向きの三角形","ダウン","幾何学","赤"], + "🔲":["黒い四角ボタン","ボタン","幾何学","正方形"], + "🔳":["白い四角ボタン","ボタン","幾何学","囲み","四角"], + "🔈":["スピーカー","音量"], + "🔉":["音量小","電源が入ったスピーカー","低い","スピーカー","音量","波"], + "🔊":["音量大","大音量のスピーカー","3","エンターテイメント","高い","音の大きい","スピーカー","ボリューム"], + "🔇":["無音のスピーカー","スピーカー","オフ","ミュート","静音","無音","音量"], + "📣":["メガホン","応援","コミュニケーション","拡声器"], + "📢":["拡声器","コミュニケーション","大声","スピーカー","パブリックアドレス","メガホン"], + "🔔":["ベル"], + "🔕":["ミュート","スラッシュベル","鐘","禁じられた","だめ","ない","禁止","静か"], + "🃏":["トランプのジョーカー","カード","エンターテイメント","ゲーム","ジョーカー","プレイ"], + "🀄":["麻雀牌の中","ゲーム","麻雀","赤"], + "♠️":["トランプのスペード","カード","ゲーム","スペード","スーツ"], + "♣️":["トランプのクラブ","カード","クラブ","ゲーム","スーツ"], + "♥️":["トランプのハート","カード","ゲーム","ハート","スーツ"], + "♦️":["トランプのダイヤ","カード","ダイヤ","ダイヤモンド","ゲーム","スーツ"], + "🎴":["花札","アクティビティ","カード","エンターテイメント","花","ゲーム","日本","プレイ"], + "👁🗨":["吹き出しの目","吹き出し","目","スピーチ","証人"], + "🗨":["左向きの吹き出し","セリフ","スピーチ"], + "💭":["考え吹き出し","吹き出し","泡","漫画","考え"], + "🗯":["右向きの怒りの吹き出し","怒り","吹き出し","泡","激怒"], + "💬":["吹き出し","泡","漫画","セリフ","スピーチ"], + "🕐":["1時","0分","1","時計","時","一"], + "🕑":["2時","0分","2","時計","時","二"], + "🕒":["3時","0分","3","時計","時","三"], + "🕓":["4時","0分","4","時計","四","時"], + "🕔":["5時","0分","5","時計","五","時"], + "🕕":["6時","0分","6","時計","時","六"], + "🕖":["7時","0分","7","時計","時","七"], + "🕗":["8時","0分","8","時計","八","時"], + "🕘":["9時","0分","9","時計","九","時"], + "🕙":["10時","0分","10","時計","時","十"], + "🕚":["11時","0分","11","時計","十一","時"], + "🕛":["12時","0分","12","時計","十二","時"], + "🕜":["1時半","1時","半","時刻","一","30"], + "🕝":["2時半","2時","半","時刻","30","二"], + "🕞":["3時半","3時","半","時刻","30","三"], + "🕟":["4時半","30","4時","時刻","四","半"], + "🕠":["5時半","30","5時","時刻","五","半"], + "🕡":["6時半","30","6時","時刻","六","半"], + "🕢":["7時半","30","7時","時刻","七","半"], + "🕣":["8時半","30","8時","時刻","八","半"], + "🕤":["9時半","30","9時","時刻","九","半"], + "🕥":["10時半","10時","半","時刻","十","30"], + "🕦":["11時半","11時","半","時刻","十一","30"], + "🕧":["12時半","12時","半","時刻","30","十二"], + "🏳":["なびく白旗","旗","なびく"], + "🏴":["なびく黒旗","旗","なびく"], + "🏁":["チェッカーフラッグ","市松模様","旗","レース"], + "🚩":["三角旗","旗","ポスト"], + "🎌":["交差旗","アクティビティ","お祝い","交差","交差した","旗","日本"], + "🏴☠️":["海賊旗","旗","海賊"], + "🏳️🌈":["レインボーフラッグ","フラッグ","レインボー","プライド","lgbt"], + "🏳️⚧️":["トラスジェンダーフラッグ","フラッグ","トランスジェンダー","プライド","lgbt"], + "🇦🇨":["アセンション島の旗","アセンション","国旗","島"], + "🇦🇩":["アンドラ国旗","アンドラ","国旗"], + "🇦🇪":["アラブ首長国連邦国旗","首長国","国旗","アラブ首長国連邦","連邦"], + "🇦🇫":["アフガニスタン国旗","アフガニスタン","国旗"], + "🇦🇬":["アンティグア・バーブーダ国旗","アンティグア","バーブーダ","国旗"], + "🇦🇮":["アンギラ島の旗","アンギラ島","国旗"], + "🇦🇱":["アルバニア国旗","アルバニア","国旗"], + "🇦🇲":["アルメニア国旗","アルメニア","国旗"], + "🇦🇴":["アンゴラ国旗","アンゴラ","国旗"], + "🇦🇶":["南極大陸の旗","南極大陸","国旗"], + "🇦🇷":["アルゼンチン国旗","アルゼンチン","国旗"], + "🇦🇸":["アメリカ領サモアの旗","アメリカ領","国旗","サモア"], + "🇦🇹":["オーストリア国旗","オーストリア","国旗"], + "🇦🇺":["オーストラリア国旗","オーストラリア","国旗","ハード","マクドナルド"], + "🇦🇼":["アルバ国旗","アルバ","国旗"], + "🇦🇽":["オーランド諸島の旗","オーランド諸島","国旗"], + "🇦🇿":["アゼルバイジャン国旗","アゼルバイジャン","国旗"], + "🇧🇦":["ボスニア・ヘルツェゴビナ国旗","ボスニア","国旗","ヘルツェゴビナ"], + "🇧🇧":["バルバドス国旗","バルバドス","国旗"], + "🇧🇩":["バングラデシュ国旗","バングラデシュ","国旗"], + "🇧🇪":["ベルギー国旗","ベルギー","国旗"], + "🇧🇫":["ブルキナファソ国旗","ブルキナファソ","国旗"], + "🇧🇬":["ブルガリア国旗","ブルガリア","国旗"], + "🇧🇭":["バーレーン国旗","バーレーン","国旗"], + "🇧🇮":["ブルンジ国旗","ブルンジ","国旗"], + "🇧🇯":["ベナン国旗","ベナン","国旗"], + "🇧🇱":["サン・バルテルミー島の旗","バルテルミー","国旗","サン"], + "🇧🇲":["バミューダ諸島の旗","バミューダ諸島","国旗"], + "🇧🇳":["ブルネイ国旗","ブルネイ","ダルサラーム","国旗"], + "🇧🇴":["ボリビア国旗","ボリビア","国旗"], + "🇧🇶":["カリブ海のオランダ領島の旗","ボネール島","カリブ海","ユースタティウス","国旗","オランダ","サバ","シント"], + "🇧🇷":["ブラジル国旗","ブラジル","国旗"], + "🇧🇸":["バハマ国旗","バハマ","国旗"], + "🇧🇹":["ブータン国旗","ブータン","国旗"], + "🇧🇼":["ボツワナ国旗","ボツワナ","国旗"], + "🇧🇾":["ベラルーシ国旗","ベラルーシ","国旗"], + "🇧🇿":["ベリーズ国旗","ベリーズ","国旗"], + "🇨🇦":["カナダ国旗","カナダ","国旗"], + "🇨🇨":["ココス諸島の旗","ココス","国旗","諸島","キーリング"], + "🇨🇩":["コンゴ国旗 - キンシャサ","コンゴ","コンゴ - キンシャサ","コンゴ民主共和国","国旗","キンシャサ","共和国"], + "🇨🇫":["中央アフリカ国旗","中央アフリカ共和国","国旗","共和国"], + "🇨🇬":["コンゴの旗 - ブラザビル","ブラザビル","コンゴ","コンゴ共和国","コンゴ - ブラザビル","国旗","共和国"], + "🇨🇭":["スイス国旗","国旗","スイス"], + "🇨🇮":["コートジボワール国旗","コートジボワール","国旗"], + "🇨🇰":["クック諸島国旗","クック","国旗","諸島"], + "🇨🇱":["チリ国旗","チリ","国旗"], + "🇨🇲":["カメルーン国旗","カメルーン","国旗"], + "🇨🇳":["中国国旗","中国","国旗"], + "🇨🇴":["コロンビア国旗","コロンビア","国旗"], + "🇨🇷":["コスタリカ国旗","コスタリカ","国旗"], + "🇨🇺":["キューバ国旗","キューバ","国旗"], + "🇨🇻":["カーボベルデ国旗","カーボ","ケープ","国旗","ベルデ"], + "🇨🇼":["キュラソー島の旗","アンティル諸島","キュラソー","国旗"], + "🇨🇽":["クリスマス島の旗","クリスマス","国旗","島"], + "🇨🇾":["キプロス国旗","キプロス","国旗"], + "🇨🇿":["チェコ国旗","チェコ共和国","国旗"], + "🇩🇪":["ドイツ国旗","国旗","ドイツ"], + "🇩🇯":["ジブチ国旗","ジブチ","国旗"], + "🇩🇰":["デンマーク国旗","デンマーク","国旗"], + "🇩🇲":["ドミニカ国旗","ドミニカ","国旗"], + "🇩🇴":["ドミニカ共和国国旗","ドミニカ共和国","国旗"], + "🇩🇿":["アルジェリア国旗","アルジェリア","国旗"], + "🇪🇨":["エクアドル国旗","エクアドル","国旗"], + "🏴":["イングランドの旗","イングランド","旗"], + "🇪🇪":["エストニア国旗","エストニア","国旗"], + "🇪🇬":["エジプト国旗","エジプト","国旗"], + "🇪🇭":["西サハラの旗","国旗","サハラ","西","西サハラ"], + "🇪🇷":["エリトリア国旗","エリトリア","国旗"], + "🇪🇸":["スペイン国旗","国旗","スペイン","セウタ","メリリャ"], + "🇪🇹":["エチオピア国旗","エチオピア","国旗"], + "🇪🇺":["欧州旗","欧州連合","旗"], + "🇫🇮":["フィンランド国旗","フィンランド","国旗"], + "🇫🇯":["フィジー国旗","フィジー","国旗"], + "🇫🇰":["フォークランド諸島の旗","フォークランド","フォークランド諸島","国旗","諸島","マルビナス"], + "🇫🇲":["ミクロネシア国旗","国旗","ミクロネシア"], + "🇫🇴":["フェロー諸島の旗","フェロー","旗","諸島"], + "🇫🇷":["フランス国旗","国旗","フランス","クリッパートン島","セント・マーチン","サン・マルタン"], + "🇬🇦":["ガボン国旗","国旗","ガボン"], + "🇬🇧":["イギリス国旗","イギリス","イギリス領","コーンウォール","イングランド","国旗","グレートブリテン","アイルランド","北アイルランド","スコットランド","UK","ユニオンジャック","連合","連合王国","ウェールズ"], + "🇬🇩":["グレナダ国旗","国旗","グレナダ"], + "🇬🇪":["ジョージア国旗","国旗","ジョージア"], + "🇬🇫":["フランス領ギアナの旗","国旗","フランス領","ギアナ"], + "🇬🇬":["ガーンジー国旗","国旗","ガーンジー"], + "🇬🇭":["ガーナ国旗","国旗","ガーナ"], + "🇬🇮":["ジブラルタル国旗","国旗","ジブラルタル"], + "🇬🇱":["グリーンランド国旗","国旗","グリーンランド"], + "🇬🇲":["ガンビア国旗","国旗","ガンビア"], + "🇬🇳":["ギニア国旗","国旗","ギニア"], + "🇬🇵":["グアドループ国旗","国旗","グアドループ"], + "🇬🇶":["赤道ギニア国旗","赤道ギニア","国旗","ギニア"], + "🇬🇷":["ギリシャ国旗","国旗","ギリシャ"], + "🇬🇸":["サウスジョージア・サウスサンドウィッチ諸島国旗","国旗","ジョージア","諸島","サウス","サウスジョージア","サウスサンドウィッチ"], + "🇬🇹":["グアテマラ国旗","国旗","グアテマラ"], + "🇬🇺":["グアム旗","国旗","グアム"], + "🇬🇼":["ギニアビサウ国旗","ビサウ","国旗","ギニア"], + "🇬🇾":["ガイアナ国旗","国旗","ガイアナ"], + "🇭🇰":["香港の旗","中国","国旗","香港"], + "🇭🇳":["ホンジュラス国旗","国旗","ホンジュラス"], + "🇭🇷":["クロアチア国旗","クロアチア","国旗"], + "🇭🇹":["ハイチ国旗","国旗","ハイチ"], + "🇭🇺":["ハンガリー国旗","国旗","ハンガリー"], + "🇮🇨":["カナリア諸島の旗","カナリア","国旗","諸島"], + "🇮🇩":["インドネシア国旗","国旗","インドネシア"], + "🇮🇪":["アイルランド国旗","国旗","アイルランド"], + "🇮🇱":["イスラエル国旗","国旗","イスラエル"], + "🇮🇲":["マン島の旗","国旗","マン島"], + "🇮🇳":["インド国旗","国旗","インド"], + "🇮🇴":["イギリス領インド洋地域の旗","イギリス領","チャゴス","旗","インド洋","島","ディエゴガルシア"], + "🇮🇶":["イラク国旗","国旗","イラク"], + "🇮🇷":["イラン国旗","国旗","イラン"], + "🇮🇸":["アイスランド国旗","国旗","アイスランド"], + "🇮🇹":["イタリア国旗","国旗","イタリア"], + "🇯🇪":["ジャージー代官管轄区の旗","国旗","ジャージー代官管轄区"], + "🇯🇲":["ジャマイカ国旗","国旗","ジャマイカ"], + "🇯🇴":["ヨルダン国旗","国旗","ヨルダン"], + "🇯🇵":["日本国旗","国旗","日本"], + "🇰🇪":["ケニア国旗","国旗","ケニア"], + "🇰🇬":["キルギス国旗","国旗","キルギス"], + "🇰🇭":["カンボジア国旗","カンボジア","国旗"], + "🇰🇮":["キリバス国旗","国旗","キリバス"], + "🇰🇲":["コモロ国旗","コモロ","国旗"], + "🇰🇳":["セントクリストファー・ネイビス国旗","国旗","キッツ","ネイビス","セント"], + "🇰🇵":["北朝鮮国旗","国旗","朝鮮","北","北朝鮮"], + "🇰🇷":["韓国国旗","国旗","韓国","南","大韓民国"], + "🇰🇼":["クウェート国旗","国旗","クウェート"], + "🇰🇾":["ケイマン諸島の旗","ケイマン","国旗","諸島"], + "🇰🇿":["カザフスタン国旗","国旗","カザフスタン"], + "🇱🇦":["ラオス国旗","国旗","ラオス"], + "🇱🇧":["レバノン国旗","国旗","レバノン"], + "🇱🇨":["セントルシア国旗","国旗","セントルシア"], + "🇱🇮":["リヒテンシュタイン国旗","国旗","リヒテンシュタイン"], + "🇱🇰":["スリランカ国旗","国旗","スリランカ"], + "🇱🇷":["リベリア国旗","国旗","リベリア"], + "🇱🇸":["レソト国旗","国旗","レソト"], + "🇱🇹":["リトアニア国旗","国旗","リトアニア"], + "🇱🇺":["ルクセンブルク国旗","国旗","ルクセンブルク"], + "🇱🇻":["ラトビア国旗","国旗","ラトビア"], + "🇱🇾":["リビア国旗","国旗","リビア"], + "🇲🇦":["モロッコ国旗","国旗","モロッコ"], + "🇲🇨":["モナコ国旗","国旗","モナコ"], + "🇲🇩":["モルドバ国旗","国旗","モルドバ"], + "🇲🇪":["モンテネグロ国旗","国旗","モンテネグロ"], + "🇲🇬":["マダガスカル国旗","国旗","マダガスカル"], + "🇲🇭":["マーシャル諸島国旗","国旗","諸島","マーシャル"], + "🇲🇰":["マケドニア国旗","国旗","マケドニア"], + "🇲🇱":["マリ国旗","国旗","マリ"], + "🇲🇲":["ミャンマー国旗","ビルマ","国旗","ミャンマー"], + "🇲🇳":["モンゴル国旗","国旗","モンゴル"], + "🇲🇴":["マカオの旗","中国","国旗","マカオ"], + "🇲🇵":["北マリアナ諸島の旗","国旗","諸島","マリアナ","北","北マリアナ"], + "🇲🇶":["マルティニークの旗","旗","マルティニーク"], + "🇲🇷":["モーリタニア国旗","国旗","モーリタニア"], + "🇲🇸":["モントセラトの旗","旗","モントセラト"], + "🇲🇹":["マルタ国旗","国旗","マルタ"], + "🇲🇺":["モーリシャス国旗","国旗","モーリシャス"], + "🇲🇻":["モルディブ国旗","国旗","モルディブ"], + "🇲🇼":["マラウイ国旗","国旗","マラウイ"], + "🇲🇽":["メキシコ国旗","国旗","メキシコ"], + "🇲🇾":["マレーシア国旗","国旗","マレーシア"], + "🇲🇿":["モザンビーク国旗","国旗","モザンビーク"], + "🇳🇦":["ナミビア国旗","国旗","ナミビア"], + "🇳🇨":["ニューカレドニアの旗","国旗","ニュー","ニューカレドニア"], + "🇳🇪":["ニジェール国旗","国旗","ニジェール"], + "🇳🇫":["ノーフォーク島の旗","旗","島","ノーフォーク"], + "🇳🇬":["ナイジェリア国旗","国旗","ナイジェリア"], + "🇳🇮":["ニカラグア国旗","国旗","ニカラグア"], + "🇳🇱":["オランダ国旗","国旗","オランダ"], + "🇳🇴":["ノルウェー国旗","旗","ノルウェー","ブーべ","スヴァールバル","ヤンマイエン"], + "🇳🇵":["ネパール国旗","国旗","ネパール"], + "🇳🇷":["ナウル国旗","国旗","ナウル"], + "🇳🇺":["ニウエ国旗","国旗","ニウエ"], + "🇳🇿":["ニュージーランド国旗","国旗","ニュー","ニュージーランド"], + "🇴🇲":["オマーン国旗","国旗","オマーン"], + "🇵🇦":["パナマ国旗","国旗","パナマ"], + "🇵🇪":["ペルー国旗","国旗","ペルー"], + "🇵🇫":["フランス領ポリネシアの旗","国旗","フランス領","ポリネシア"], + "🇵🇬":["パプアニューギニア国旗","国旗","ギニア","ニュー","パプアニューギニア"], + "🇵🇭":["フィリピン国旗","国旗","フィリピン"], + "🇵🇰":["パキスタン国旗","国旗","パキスタン"], + "🇵🇱":["ポーランド国旗","国旗","ポーランド"], + "🇵🇲":["サンピエール島・ミクロン島の旗","旗","ミクロン","ピエール","サン"], + "🇵🇳":["ピトケアン諸島の旗","旗","諸島","ピトケアン"], + "🇵🇷":["プエルトリコの旗","国旗","プエルトリコ"], + "🇵🇸":["パレスチナ自治政府の旗","国旗","パレスチナ"], + "🇵🇹":["ポルトガル国旗","国旗","ポルトガル"], + "🇵🇼":["パラオ国旗","国旗","パラオ"], + "🇵🇾":["パラグアイ国旗","国旗","パラグアイ"], + "🇶🇦":["カタール国旗","国旗","カタール"], + "🇷🇪":["レユニオンの旗","旗","レユニオン"], + "🇷🇴":["ルーマニア国旗","国旗","ルーマニア"], + "🇷🇸":["セルビア国旗","国旗","セルビア"], + "🇷🇺":["ロシア国旗","国旗","ロシア"], + "🇷🇼":["ルワンダ国旗","国旗","ルワンダ"], + "🇸🇦":["サウジアラビア国旗","国旗","サウジアラビア"], + "🏴":["スコットランドの旗","スコットランド","旗"], + "🇸🇧":["ソロモン諸島国旗","旗","諸島","ソロモン"], + "🇸🇨":["セーシェル国旗","国旗","セーシェル"], + "🇸🇩":["スーダン国旗","国旗","スーダン"], + "🇸🇪":["スウェーデン国旗","国旗","スウェーデン"], + "🇸🇬":["シンガポール国旗","国旗","シンガポール"], + "🇸🇭":["セントヘレナ島の旗","旗","ヘレナ","セント"], + "🇸🇮":["スロベニア国旗","国旗","スロベニア"], + "🇸🇰":["スロバキア国旗","国旗","スロバキア"], + "🇸🇱":["シエラレオネ国旗","国旗","シエラレオネ"], + "🇸🇲":["サンマリノ国旗","国旗","サンマリノ"], + "🇸🇳":["セネガル国旗","国旗","セネガル"], + "🇸🇴":["ソマリア国旗","国旗","ソマリア"], + "🇸🇷":["スリナム国旗","国旗","スリナム"], + "🇸🇸":["南スーダン国旗","国旗","南","南スーダン","スーダン"], + "🇸🇹":["サントメ・プリンシペ国旗","国旗","プリンシペ","プリンシピ","サントメ","サォントメー"], + "🇸🇻":["エルサルバドル国旗","エルサルバドル","国旗"], + "🇸🇽":["セント・マーチン島の旗","旗","マーチン","セント"], + "🇸🇾":["シリア国旗","国旗","シリア"], + "🇸🇿":["スワジランド国旗","国旗","スワジランド"], + "🇹🇦":["トリスタンダクーニャの旗","旗","トリスタン・ダ・クーニャ"], + "🇹🇨":["タークス・カイコス諸島の旗","カイコス","旗","諸島","タークス"], + "🇹🇩":["チャド国旗","チャド","国旗"], + "🇹🇫":["フランス領南方・南極地域の旗","南極","国旗","フランス領"], + "🇹🇬":["トーゴ国旗","国旗","トーゴ"], + "🇹🇭":["タイ国旗","国旗","タイ"], + "🇹🇯":["タジキスタン国旗","国旗","タジキスタン"], + "🇹🇰":["トケラウ旗","国旗","トケラウ"], + "🇹🇱":["東ティモール国旗","東","東ティモール","国旗","ティモール・レステ"], + "🇹🇲":["トルクメニスタン国旗","国旗","トルクメニスタン"], + "🇹🇳":["チュニジア国旗","国旗","チュニジア"], + "🇹🇴":["トンガ国旗","国旗","トンガ"], + "🇹🇷":["トルコ国旗","国旗","トルコ"], + "🇹🇹":["トリニダード・トバゴ国旗","国旗","トバゴ","トリニダード"], + "🇹🇻":["ツバル国旗","国旗","ツバル"], + "🇹🇼":["台湾の旗","中国","国旗","台湾"], + "🇹🇿":["タンザニア国旗","国旗","タンザニア"], + "🇺🇦":["ウクライナ国旗","国旗","ウクライナ"], + "🇺🇬":["ウガンダ国旗","国旗","ウガンダ"], + "🇺🇳":["国連の旗","旗","国連","連合","国際"], + "🇺🇸":["アメリカ国旗","アメリカ","旗","合衆","合衆国","アメリカ合衆国","合衆国領有小離島"], + "🇺🇾":["ウルグアイ国旗","国旗","ウルグアイ"], + "🇺🇿":["ウズベキスタン国旗","国旗","ウズベキスタン"], + "🇻🇦":["バチカン市国旗","国旗","バチカン"], + "🇻🇨":["セントビンセント・グレナディーン国旗","国旗","グレナディーン諸島","セント","ビンセント"], + "🇻🇪":["ベネズエラ国旗","国旗","ベネズエラ"], + "🇻🇬":["イギリス領ヴァージン諸島の旗","イギリス領","国旗","島","ヴァージン"], + "🇻🇮":["アメリカ領ヴァージン諸島の旗","アメリカ","国旗","島","アメリカ合衆国","合衆国","ヴァージン"], + "🇻🇳":["ベトナム国旗","国旗","ベトナム","ヴェトナム"], + "🇻🇺":["バヌアツ国旗","国旗","バヌアツ"], + "🏴":["ウェールズの旗","ウェールズ","旗"], + "🇼🇫":["ウォリス・フツナの旗","国旗","フツナ","ウォリス"], + "🇼🇸":["サモア国旗","国旗","サモア"], + "🇽🇰":["コソボ国旗","国旗","コソボ"], + "🇾🇪":["イエメン国旗","国旗","イエメン"], + "🇾🇹":["マヨットの旗","国旗","マヨット"], + "🇿🇦":["南アフリカ国旗","国旗","南","南アフリカ"], + "🇿🇲":["ザンビア国旗","国旗","ザンビア"], + "🇿🇼":["ジンバブエ国旗","国旗","ジンバブエ"], + "": ["渋谷109", "SHIBUYA109", "109"] +} diff --git a/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json b/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json new file mode 100644 index 0000000000..2ad282d501 --- /dev/null +++ b/packages/frontend/src/unicode-emoji-indexes/ja-JP_hira.json @@ -0,0 +1,1866 @@ +{ + "😀": ["にやにやしたかお","かお","にやにや","しあわせ"], + "😃": ["くちをあけたえがお","かお","くち","あける","えがお","しあわせ"], + "😄": ["くちをあけてめがわらっているえがお","め","かお","くち","あける","えがお","しあわせ"], + "😁": ["にやにやしたかお","め","かお","にやにや","えがお"], + "😆": ["くちをあけてわらっているかお","かお","わらい","くち","あける","まんぞく","えがお"], + "😅": ["くちをあけてひやあせをかいたえがお","ぞっとする","かお","くちをあける","えがお","ひやあせ"], + "😂": ["うれしなき","かお","うれしい","わらう","なく","なみだ"], + "🤣": ["だいばくしょう","かお","ゆか","わらい","おおわらい","ばくしょう","ぐるぐる"], + "😇": ["てんしのえがお","てんし","かお","おとぎばなし","ふぁんたじー","てんしのわ","むじゃき","えがお"], + "😉": ["ういんくしたかお","かお","ういんく"], + "😊": ["めがわらっているえがお","せきめん","め","かお","えがお"], + "🙂": ["ほほえみ","かお","えがお","しあわせ"], + "🙃": ["さかさのかお","かお","さかさ"], + "☺️": ["えがお","かお","りんかく","りらっくす"], + "😋": ["たべものをあじわうかお","おいしい","かお","あじわう","ふーむ","うまい"], + "😌": ["ほっとしたかお","かお","あんしん","ほっとする"], + "😍": ["めがはーとのえがお","め","かお","はーと","あい","えがお"], + "🥰": ["えがおとはーと","かお","けいあい","べたぼれ","あい"], + "😘": ["なげきっす","かお","はーと","きす"], + "😗": ["きすをするかお","かお","きす"], + "😙": ["えがおできす","め","かお","きす","えがお"], + "😚": ["めをとじてきすをするかお","とじた","め","かお","きす"], + "🥲": ["なみだのでているえがお","なく","しあわせ","かんしゃする","ほこりにおもう","あんしんする","わらう"], + "🤪": ["おどけたかお","め","にやにや","へん","こうふん","わいるど"], + "😜": ["したをだしてういんくしているかお","め","かお","じょうだん","した","ういんく"], + "😝": ["したをだしてめをほそめているかお","め","かお","こわい","あじ","した"], + "😛": ["したをだしているかお","かお","した"], + "🤑": ["ごうよくなかお","かお","おかね","くち"], + "😎": ["さんぐらすをかけたかお","あかるい","かっこいい","め","あいうぇあ","かお","めがね","えがお","たいよう","さんぐらす","てんき"], + "🤓": ["おたく","かお","へんなひと"], + "🥸": ["かそうしたかお","かそう","めがね","とくめいのひと","はな"], + "🧐": ["かためがねをかけたかお","たいくつ","ゆうふく","ゆたか"], + "🤠": ["かうぼーいはっとのかお","かうぼーい","かうがーる","かお","ぼうし"], + "🥳": ["ぱーてぃーふぇいす","かお","しゅくてん","ぼうし","つの","ぱーてぃー"], + "🤡": ["ぴえろのかお","ぴえろ","かお"], + "😏": ["にやにやしたかお","かお","にやにや"], + "😶": ["くちのないかお","かお","くち","しずかに","ちんもく"], + "🫥": ["てんせんのかお","おちこんだ","きえる","かくれる","ないこうてき","めにみえない"], + "😐": ["ふつうのかお","むひょうじょう","かお","へいせい"], + "🫤": ["くちがななめになったかお","がっかり","むかんしん","うたがいぶかい","ふあん"], + "😑": ["むひょうじょう","かお","ぽーかーふぇいす","むかんじょう"], + "😒": ["おもしろくなさそうなかお","かお","つまらない","ふこう"], + "🙄": ["ぐるぐるめのかお","め","かお","ぐるぐる"], + "🤨": ["まゆがあがっているかお","ふしん","うたがいぶかい","ひなん","ぎねん","ややおどろき","かいぎてき"], + "🤔": ["かんがえているかお","かお","かんがえちゅう"], + "🤫": ["しっといっているかお","しーっ","しずか","だまる"], + "🤭": ["くちをてでおおったかお","め","えがお","おおう","くち","て"], + "🫢": ["めをひらいてくちをてでおおったかお","きょうたん","いけい","ふしん","ろうばい","こわい","おどろき"], + "🫡": ["けいれいしているかお","ok","けいれい","せいてん","ぶたい","はい"], + "🤗": ["りょうてをひろげたえがお","かお","はぐ","だきしめる"], + "🫣": ["のぞきみしているかお","みりょう","のぞきみ","ぎょうし","ちらみ"], + "🤥": ["うそつきがお","かお","うそ","ぴのきお"], + "😳": ["あかくなったかお","ぼーっとした","ぼうっとした","かお","せきめん"], + "😞": ["がっかりしたかお","がっかり","かお"], + "😟": ["ふあんなかお","かお","しんぱい","ふあん"], + "😤": ["かちほこったかお","かお","しょうり","かつ"], + "😠": ["おこったかお","いかり","おこった","かお","げきど"], + "😡": ["ふくれがお","いかり","おこった","かお","げきど","ふくれっつら","ふんど","あか"], + "🤬": ["くちがきごうでおおわれたかお","のろい","ののしり"], + "😔": ["かなしげなかお","がっかり","かお","かなしい"], + "😕": ["こまったかお","こまった","かお"], + "🙁": ["ごきげんななめ","かお","しかめっつら","かなしい","ふこう"], + "☹": ["しかめっつら","かお","かなしい","ふこう"], + "😬": ["しかめっつら","かお"], + "🥺": ["うったえかけるかお","かお","ものごい","じひ","こいぬのめ"], + "😣": ["がまんしているかお","かお","がんばる"], + "😖": ["うろたえたかお","とまどい","うろたえ","かお"], + "😫": ["つかれたかお","かお","つかれた"], + "😩": ["うんざりしているかお","かお","つかれた","うんざり"], + "🥱": ["あくびしているかお","あきた","つかれた","あくび"], + "😪": ["ねむいかお","かお","ねる","すいみん"], + "😮💨": ["ためいきのでているかお","かお","ためいき","いきぎれ","うめき","あんしん","ささやき","くちぶえ"], + "😮": ["くちをあけたえがお","かお","くち","あける","どうじょう"], + "😱": ["ぜっきょうしたかお","かお","きょうふ","こわい","むんく","おびえ","ぜっきょう"], + "😨": ["ぞっとしているかお","かお","きょうふ","こわい","おびえ"], + "😰": ["くちをあけてひやあせをかいたかお","あおざめる","ぞっとする","かお","くち","あける","いそぐ","ひやあせ"], + "😥": ["がっかりしたがあんしんしたかお","がっかり","かお","あんしん","ほっとする","やれやれ"], + "😓": ["ひやあせをかいているかお","ぞっとする","かお","ひやあせ"], + "😯": ["おちついたかお","かお","だまる","ぼうぜん","おどろき"], + "😦": ["しんぱいそうなかおのえもじ","かお","しかめっつら","くち","あける"], + "😧": ["くのうにみちたかお","くのう","かお"], + "🥹": ["なみだをこらえているかお","おこる","なく","ほこりにおもう","さからう","かなしむ"], + "😢": ["なきがお","なく","かお","かなしい","なみだ"], + "😭": ["ごうきゅう","なく","かお","かなしい","なみだ"], + "🤤": ["よだれをたらしたかお","よだれ","かお"], + "🤩": ["すたーにむちゅう","め","かお","にやにや","ほし","むそうてき"], + "😵": ["めがばつになったかお","めまい","かお","ばつ","め"], + "😵💫": ["めがぐるぐるしているかお","めまい","かお","め","うっとり","ぐるぐる","とらぶる","おー"], + "🥴": ["ぼんやしりたかお","かお","めまい","めいてい","ほろよい","まっすぐでないめ","はじょうのくち"], + "😲": ["おどろいたかお","おどろき","びっくり","かお","しょっく","きょうがく"], + "🫨": ["ふるえるかお","じしん","かお","ふるえ","しょうげき","しんどう"], + "🤯": ["ばくはつしたあたま","かお","しょっく","ばくはつ","きょうき","びっくり"], + "🫠": ["ほろりとしたかお","きえる","ようかいする","えきたい","とける"], + "🤐": ["おくちちゃっく","かお","くち","ちゃっく"], + "😷": ["ますくをしたかお","かぜ","いしゃ","かお","ますく","くすり","びょうき"], + "🤕": ["けが","ほうたい","かお","きず"], + "🤒": ["おんどけいをくわえたかお","かお","びょうき","かぜ","たいおんけい"], + "🤮": ["はきそうなかお","びょうき","おうと","かぜ","はく"], + "🤢": ["はきそうなかお","かお","はきけ","おうと"], + "🤧": ["くしゃみをするかお","かお","くしゃみ","はくしょん"], + "🥵": ["ほてったかお","かお","ねつっぽい","ねっしゃびょう","ほてった","あからがお","あせをかいた"], + "🥶": ["あおざめたかお","かお","ぞっとする","こごえる","とうしょう","つらら"], + "😶🌫️": ["くもでおおわれたかお","かお","おっちょこちょい","ひげんじつてき","ゆめ","もや","くもでおおわれたあたま"], + "😴": ["ねがお","かお","ねる","すいみん","すやすや"], + "💤": ["すいみん","まんが","ねる","すやすや"], + "😈": ["つのつきえがお","かお","おとぎばなし","ふぁんたじー","つの","えがお"], + "👿": ["しょうあくま","おに","あくま","かお","おとぎばなし","ふぁんたじー"], + "👹": ["おに","ようかい","かお","むかしばなし","ふぁんたじー","にっぽん","もんすたー"], + "👺": ["てんぐ","ようかい","かお","むかしばなし","ふぁんたじー","にっぽん","もんすたー"], + "💩": ["うんち","まんが","ふん","かお","もんすたー"], + "👻": ["おばけ","ようかい","かお","おとぎばなし","ふぁんたじー","ゆうれい","もんすたー","はろうぃーん"], + "💀": ["どくろ","からだ","し","かお","おとぎばなし","もんすたー","がいこつ","はろうぃーん"], + "☠": ["どくろまーく","からだ","こうさしたほね","し","かお","もんすたー","がいこつ","はろうぃーん"], + "👽": ["うちゅうじん","かいじゅう","いせいじん","かお","おとぎばなし","ふぁんたじー","もんすたー","うちゅう","UFO"], + "🤖": ["ろぼっとのかお","かお","もんすたー","ろぼっと"], + "🎃": ["じゃっく・お・らんたん","いべんと","おいわい","えんため","はろうぃん","じゃっくおらんたん","らんたん","かぼちゃ"], + "😺": ["くちをあけてわらうねこ","ねこ","かお","くち","あける","えがお"], + "😸": ["にやにやわらうねこ","ねこ","め","かお","にやにや","えがお"], + "😹": ["うれしなきしたねこのかお","ねこ","かお","うれしい","なみだ"], + "😻": ["はーとのめをしたねこのえがお","ねこ","め","かお","はーと","あい","えがお"], + "😼": ["にやりとわらうねこのかお","ねこ","かお","ひにく","えがお","にやり"], + "😽": ["めをとじてきすをするねこ","ねこ","め","かお","きす"], + "🙀": ["つかれたねこのかお","ねこ","かお","びっくり","おどろく","うんざり"], + "😿": ["ないたねこのかお","ねこ","なく","かお","かなしい","なみだ"], + "😾": ["おこったねこのかお","ねこ","かお","おこる","ふくれっつら"], + "🫶": ["はーとぽーず","あい"], + "👐": ["ひらいたて","からだ","て","ひろげる"], + "🤲": ["うえにむけたりょうてのひら","からだ","いのり","かっぷのようにまるめたて"], + "🙌": ["りょうてをあげる","からだ","おいわい","じぇすちゃー","て","ばんざい","あげる"], + "👏": ["はくしゅ","からだ","てをたたく","て"], + "🙏": ["にぎったて","たのむ","からだ","おじぎ","てをあわせる","じぇすちゃー","て","おねがい","いのる","ありがとう","かんしゃ"], + "🤝": ["あくしゅ","ごうい","て","しゅをむすぶ","かいぎ"], + "👍": ["いいね","からだ","うえ","て","ゆび","さむずあっぷ","+1"], + "👎": ["だめ","からだ","した","て","ゆび","さむずだうん","-1"], + "👊": ["にぎりこぶし","からだ","にぎる","こぶし","ぐー","て","ぱんち","せっきん"], + "✊": ["こぶし","からだ","にぎる","ぐー","て","ぱんち"], + "🤛": ["ひだりむきのこぶし","からだ","こぶし","ひだりむき"], + "🤜": ["みぎむきのこぶし","からだ","こぶし","みぎむき"], + "🤞": ["こうささせたゆび","からだ","こうさ","ゆび","て","こううん"], + "✌": ["Vさいん","からだ","て","V","ぶい","かつ","しょうり","ぴーす"], + "🫰": ["ひとさしゆびとおやゆびをこうさしたて","たかい","はーと","あい","おかね","すなっぷ"], + "🤘": ["こるな","からだ","ゆび","て","つの","さいこう"], + "🤟": ["あいしてるのじぇすちゃー","からだ","あいしてる","すき","て"], + "👌": ["OKさいん","からだ","て","OK"], + "🤌": ["つまんでいるゆび","ゆび","てぶり","じんもん","つまむ","ひにく"], + "🤏": ["つまんでいるて","からだ","て","ちいさい","こがた","ちっちゃい"], + "👈": ["ひだりゆびさし","てのこう","からだ","ゆび","て","ひとさしゆび","ゆびさす"], + "🫳": ["てのひらをしたにしたて","しりぞける","おとす","しっし"], + "🫴": ["てのひらをうえにしたて","てまねき","ほかく","くる","もうしで"], + "👉": ["ゆびさし","てのこう","からだ","ゆび","て","ひとさしゆび","ゆびさす"], + "👆": ["ゆびさし","てのこう","からだ","ゆび","て","ひとさしゆび","ゆびさす","うえ"], + "👇": ["ゆびさし","てのこう","からだ","した","ゆび","て","ひとさしゆび","ゆびさす"], + "☝": ["ゆびさし","からだ","ゆび","て","ひとさしゆび","ゆびさす","うえ"], + "✋": ["きょしゅ","からだ","て"], + "🤚": ["てのこう","からだ","あげる"], + "🖐": ["ひろげたてのひら","からだ","ゆび","て","ひろげる"], + "🖖": ["ちょうじゅとはんえいを","からだ","ゆび","て","すぽっく","ばるかん"], + "👋": ["ばいばい","からだ","て","ふる","やっほー","こんにちは"], + "🤙": ["でんわのかたちのて","からだ","でんわ","て"], + "🫲": ["ひだりて","て","ひだり"], + "🫱": ["みぎて","て","みぎ"], + "🫷": ["ひだりをおしているて","じたい","はいたっち","ひだりほうこう","おしつける","ことわる","ていし","まつ"], + "🫸": ["みぎをおしているて","じたい","はいたっち","おしつける","ことわる","みぎほうこう","ていし","まつ"], + "💪": ["まげたじょうわんにとうきん","ちからこぶ","からだ","まんが","うんどう","きんにく","ちから","まっする","まっちょ"], + "🦾": ["めかにかるあーむ","あくせしびりてぃ","ぎしゅ","じんこうそうぐ","からだ"], + "🖕": ["なかゆびをたてたて","からだ","ゆび","て","なかゆび"], + "🫵": ["みているひとをさしているひとさしゆび","さす","あなた","ゆび"], + "✍": ["かいているて","からだ","て","かく"], + "🤳": ["じどり","かめら","けいたい","うで"], + "💅": ["まにきゅあ","からだ","けあ","けしょうひん","こすめ","つめ","ねいる"], + "🦵": ["あし","からだ","きっく","てあし"], + "🦿": ["きかいのあし","あくせしびりてぃ","ぎそく","じんこうそうぐ","からだ"], + "🦶": ["あし","からだ","きっく","ふみつける"], + "👄": ["くち","からだ","くちびる"], + "🫦": ["かんでいるくちびる","しんぱい","こわい","うわき","しんけいしつ","ふゆかい","ふあん"], + "🦷": ["は","からだ","はいしゃ"], + "👅": ["した","からだ"], + "👂": ["みみ","からだ","はな"], + "🦻": ["ほちょうきをつけているみみ","あくせしびりてぃ","ほちょうき","きく","からだ","みみ"], + "👃": ["はな","からだ"], + "👁": ["め","からだ"], + "👀": ["め","からだ","かお"], + "🧠": ["のう","からだ","ぞうき","ちてき","かしこい"], + "🫀": ["かいぼうがくてきなしんぞう","かいぼうがく","しんぞうがく","しんぞう","ぞうき","みゃく"], + "🫁": ["はい","いき","こき","きゅうにゅう","ぞうき","こきゅう"], + "🦴": ["ほね","からだ","こっかく"], + "👤": ["じょうはんしんのしるえっと","じょうはんしん","しるえっと"], + "👥": ["じょうはんしんのしるえっと","じょうはんしん","しるえっと"], + "🗣": ["しゃべるあたまのしるえっと","かお","あたま","しるえっと","しゃべる","はなす"], + "🫂": ["はぐしているひとたち","さようなら","こんにちは","はぐ","ありがとう"], + "👶": ["あかちゃん"], + "👧": ["おんなのこ","しょうじょ","しょじょ","おとめざ","せいざ","こども"], + "🧒": ["こども","ひと","しょうねん","しょうじょ"], + "👦": ["おとこのこ","しょうねん","こども"], + "👩": ["じょせい","おんな"], + "🧑": ["せいじんむけ","ひと","おとな","だんせい","じょせい","おんな","おとこ"], + "👨": ["だんせい","くちひげ","おとこ"], + "👩🦱": ["じょせい","まきげ","かみ","おんな"], + "🧑🦱": ["ひと","まきげ","かみ"], + "👨🦱": ["だんせい","まきげ","かみ","おとこ"], + "👩🦰": ["じょせい","あかげ","あか","かみ","おんな"], + "🧑🦰": ["ひと","あかげ","あか","かみ"], + "👨🦰": ["だんせい","あかげ","あか","かみ","おとこ"], + "👱♀️": ["じょせい","きんぱつ","ぶろんど","かみ","おんな"], + "👱": ["ひと","きんぱつ","ぶろんど","かみ"], + "👱♂️": ["だんせい","きんぱつ","ぶろんど","かみ","おとこ"], + "👩🦳": ["じょせい","はくはつ","しろ","かみ","おんな"], + "🧑🦳": ["ひと","はくはつ","しろ","かみ"], + "👨🦳": ["だんせい","はくはつ","しろ","かみ","おとこ"], + "👩🦲": ["じょせい","はげ","おんな"], + "🧑🦲": ["ひと","はげ"], + "👨🦲": ["だんせい","はげ","おとこ"], + "🧔♀️": ["ひげのあるじょせい","あごひげ","ひげをはやした","じょせい","おんな"], + "🧔": ["あごひげのあるひと","あごひげ","ひげをはやした"], + "🧔♂️": ["ひげのあるだんせい","あごひげ","ひげをはやした","だんせい","おとこ"], + "👵": ["おばあさん","おばあちゃん","ろうじん","じょせい","おんな"], + "🧓": ["こうれいしゃ","ひと","だんせい","じょせい","おんな","おとこ"], + "👴": ["おじいさん","おじいちゃん","ろうじん","おとこ","だんせい"], + "👲": ["すかるきゃっぷをかぶっているひと","ちゅうごくぼう","ぼうし"], + "👳♀️": ["たーばんをまいているじょせい","たーばん","じょせい","おんな"], + "👳": ["たーばんをまいているひと","たーばん"], + "👳♂️": ["たーばんをまいているだんせい","たーばん","おとこ","だんせい"], + "🧕": ["へっどすかーふをかぶったじょせい","へっどすかーふ","ひじゃぶ","まんてぃら","てぃちぇる","ばんだな","あたまのすかーふ","じょせい","おんな"], + "👮♀️": ["じょせいけいさつかん","けいさつかん","けいかん","けいさつ","じょせい","おんな"], + "👮": ["けいさつかん","けいかん","けいさつ"], + "👮♂️": ["だんせいけいさつかん","けいさつかん","けいかん","けいさつ","おとこ","だんせい"], + "👩🚒": ["じょせいしょうぼうし","ひ","かじ","しょうぼう","しょうぼうし","じょせい","おんな"], + "🧑🚒": ["しょうぼうし","かじ"], + "👨🚒": ["だんせいしょうぼうし","ひ","かじ","しょうぼう","しょうぼうし","おとこ","だんせい"], + "👷♀️": ["じょせいのけんせつさぎょういん","こうじ","けんせつ","さぎょういん","じょせい","おんな"], + "👷": ["けんせつさぎょういん","こうじ","けんせつ","さぎょういん"], + "👷♂️": ["だんせいのけんせつさぎょういん","けんせつ","さぎょういん","だんせい","おとこ"], + "👩🏭": ["だんせいのこうじょうさぎょういん","こうじょう","こうぎょう","さぎょういん","じょせい","おんな"], + "🧑🏭": ["こうじょうさぎょういん","こうじょう","こうぎょう","ようせつ"], + "👨🏭": ["だんせいのこうじょうさぎょういん","こうじょう","こうぎょう","さぎょういん","おとこ","だんせい"], + "👩🔧": ["じょせいせいびし","しょくにん","はいかんこう","でんきぎし","しゅうりにん","じょせい","おんな"], + "🧑🔧": ["せいびし","しょくにん","はいかんこう","でんきぎし","しゅうりじん"], + "👨🔧": ["だんせいせいびし","しょくにん","はいかんこう","でんきぎし","しゅうりじん","おとこ","だんせい"], + "👩🌾": ["じょせいののうぎょうじゅうじしゃ","のうじょうろうどうしゃ","ぼくじょうぬし","にわし","のうか","じょせい","おんな"], + "🧑🌾": ["のうぎょうじゅうじしゃ","のうじょうろうどうしゃ","ぼくじょうぬし","にわし","のうか"], + "👨🌾": ["だんせいののうぎょうじゅうじしゃ","のうじょうろうどうしゃ","ぼくじょうぬし","にわし","のうか","おとこ","だんせい"], + "👩🍳": ["じょせいのりょうりにん","しょくひん","さーびす","しぇふ","こっく","りょうりにん","りょうり","じょせい","おんな"], + "🧑🍳": ["りょうりにん","しょくひん","さーびす","しぇふ","こっく","りょうり"], + "👨🍳": ["だんせいのりょうりじん","しょくひん","さーびす","しぇふ","こっく","りょうりにん","りょうり","おとこ","だんせい"], + "👩🎤": ["だんせいしんがー","おんがく","みゅーじしゃん","ろっく","ろっかー","ろっくすたー","げいのうじん","じょせい","おんな"], + "🧑🎤": ["かしゅ","おんがく","みゅーじしゃん","ろっく","ろっかー","ろっくすたー","げいのうじん"], + "👨🎤": ["だんせいしんがー","おんがく","みゅーじしゃん","ろっく","ろっかー","ろっくすたー","げいのうじん","おとこ","だんせい"], + "👩🎨": ["じょせいあーてぃすと","げいじゅつ","あーと","げいじゅつか","あーてぃすと","かいが","がか","じょせい","おんな"], + "🧑🎨": ["あーてぃすと","げいじゅつ","あーと","げいじゅつか","かいが","がか"], + "👨🎨": ["だんせいあーてぃすと","げいじゅつ","あーと","げいじゅつか","あーてぃすと","かいが","がか","おとこ","だんせい"], + "👩🏫": ["じょせいのきょうし","きょういく","せんせい","きょうじゅ","きょうし","こうし","じょせい","おんな"], + "🧑🏫": ["きょうし","きょういく","せんせい","きょうじゅ","こうし"], + "👨🏫": ["だんせいのきょうし","きょういく","せんせい","きょうじゅ","きょうし","こうし","おとこ","だんせい"], + "👩🎓": ["じょしせいと","がくせい","そつぎょうせい","きょういく","がっこう","じょせい","おんな"], + "🧑🎓": ["せいと","がくせい","そつぎょうせい","きょういく","がっこう"], + "👨🎓": ["だんしせいと","がくせい","そつぎょうせい","きょういく","がっこう","おとこ","だんせい"], + "👩💼": ["だんせいかいしゃいん","おふぃす","かいけいし","ぎんこうか","かんりしょく","こもん","じむいん","あなりすと","じょせい","おんな"], + "🧑💼": ["かいしゃいん","おふぃす","かいけいし","ぎんこうか","かんりしょく","こもん","じむいん","あなりすと"], + "👨💼": ["だんせいかいしゃいん","おふぃす","かいけいし","ぎんこうか","かんりしょく","こもん","じむいん","あなりすと","おとこ","だんせい"], + "👩💻": ["じょせいぎじゅつしゃ","てくのろじー","そふとうぇあ","えんじにあ","ぷろぐらまー","らっぷとっぷ","のーとぱそこん","じょせい","おんな"], + "🧑💻": ["ぎじゅつしゃ","てくのろじー","そふとうぇあ","えんじにあ","ぷろぐらまー","らっぷとっぷ","のーとぱそこん"], + "👨💻": ["だんせいぎじゅつしゃ","てくのろじー","そふとうぇあ","えんじにあ","ぷろぐらまー","らっぷとっぷ","のーとぱそこん","おとこ","だんせい"], + "👩🔬": ["じょせいかがくしゃ","かがくしゃ","ぎじゅつしゃ","すうがくしゃ","ぶつりがくしゃ","せいぶつがくしゃ","けんさぎし","じょせい","おんな"], + "🧑🔬": ["かがくしゃ","ぎじゅつしゃ","すうがくしゃ","ぶつりがくしゃ","せいぶつがくしゃ","けんさぎし"], + "👨🔬": ["だんせいかがくしゃ","かがくしゃ","ぎじゅつしゃ","すうがくしゃ","ぶつりがくしゃ","せいぶつがくしゃ","けんさぎし","おとこ","だんせい"], + "👩🚀": ["じょせいうちゅうひこうし","うちゅう","ほし","つき","わくせい","じょせい","おんな"], + "🧑🚀": ["うちゅうひこうし","うちゅう","ほし","つき","わくせい"], + "👨🚀": ["だんせいうちゅうひこうし","うちゅう","ほし","つき","わくせい","おとこ","だんせい"], + "👩⚕️": ["じょせいいりょうかんけいしゃ","いし","ないかい","いがくはかせ","かんごし","しかい","いりょうせんもんか","りょうほうし","じょせい","おんな"], + "🧑⚕️": ["いりょうかんけいしゃ","いし","ないかい","いがくはかせ","かんごし","しかい","いりょうせんもんか","りょうほうし"], + "👨⚕️": ["だんせいいりょうかんけいしゃ","いし","ないかい","いがくはかせ","かんごし","しかい","いりょうせんもんか","りょうほうし","おとこ","だんせい"], + "👩⚖️": ["じょせいさいばんかん","さいばんかん","ほうてい","さいばんしょ","ほうりつ","じょせい","おんな"], + "🧑⚖️": ["さいばんかん","ほうてい","さいばんしょ","ほうりつ"], + "👨⚖️": ["だんせいさいばんかん","さいばんかん","ほうてい","さいばんしょ","ほうりつ","おとこ","だんせい"], + "👩✈️": ["じょせいぱいろっと","ぱいろっと","ひこうき","そうじゅうし","こうくう","じょせい","おんな"], + "🧑✈️": ["ぱいろっと","ひこうき","そうじゅうし","こうくう"], + "👨✈️": ["だんせいぱいろっと","ぱいろっと","ひこうき","そうじゅうし","こうくう","おとこ","だんせい"], + "💂♀️": ["じょせいけいびいん","けいびいん","けいび","じょせい","おんな"], + "💂": ["けいびいん","けいび"], + "💂♂️": ["だんせいけいびいん","けいびいん","けいび","おとこ","だんせい"], + "🥷": ["にんじゃ","せんし","かくされた","すてるす"], + "🕵️♀️": ["じょせいのたんてい","たんてい","けいじ","すぱい","じょせい","おんな"], + "🕵": ["たんてい","けいじ","すぱい"], + "🕵️♂️": ["だんせいのたんてい","たんてい","けいじ","すぱい","おとこ","だんせい"], + "🤶": ["みせす・くろーす","いべんと","おいわい","くりすます","はは","さんた","くろーす","じょせい","おんな"], + "🧑🎄": ["みくすくろーす","あくてぃびてぃ","おいわい","くりすます","さんた","くろーす"], + "🎅": ["さんたくろーす","いべんと","おいわい","くりすます","ちち","さんた","くろーす","おとこ","だんせい"], + "👼": ["てんしのあかちゃん","てんし","あかちゃん","かお","おとぎばなし","ふぁんたじー"], + "👸": ["おひめさま","おとぎばなし","ふぁんたじー","じょおう","じょせい","おんな"], + "🫅": ["おうかんをかぶったひと","おとぎばなし","ふぁんたじー","こくおう","きぞく","おう","おうぞく"], + "🤴": ["おうじさま","おとぎばなし","ふぁんたじー","おう","おとこ","だんせい"], + "👰": ["べーるをつけたじょせい","はなよめ","べーる","けっこんしき","じょせい","おんな"], + "👰♀️": ["べーるをつけたひと","はなよめ","べーる","けっこんしき"], + "👰♂️": ["べーるをつけただんせい","はなよめ","べーる","うぇでぃんぐ","だんせい","おとこ"], + "🤵♀️": ["たきしーどのじょせい","たきしーど","うぇでぃんぐ","じょせい","おんな"], + "🤵": ["たきしーどをきるひと","はなむこ","たきしーど","うぇでぃんぐ"], + "🤵♂️": ["たきしーどのだんせい","はなむこ","たきしーど","うぇでぃんぐ","だんせい","おとこ"], + "🩷": ["ぴんくのはーと","かわいい","はーと","すき","あい","ぴんく"], + "🩵": ["らいとぶるーのはーと","しあん","はーと","らいとぶるー","こがも"], + "🩶": ["ぐれーのはーと","ぐれー","はーと","しるばー","すれーと"], + "🕴️♀️": ["ちゅうにういたすーつのじょせい","びじねす","すーつ","じょせい","おんな"], + "🕴": ["ちゅうにういたすーつのひと","びじねす","すーつ"], + "🕴️♂️": ["ちゅうにういたすーつのだんせい","びじねす","すーつ","おとこ","だんせい"], + "🦸♀️": ["じょせいのすーぱーひーろー","くうそう","ぜん","ひろいん","ちょうたいこく","じょせい","おんな"], + "🦸": ["すーぱーひーろー","くうそう","ぜん","ひーろー","ひろいん","ちょうたいこく"], + "🦸♂️": ["だんせいのすーぱーひーろー","くうそう","ぜん","ひーろー","ちょうたいこく","だんせい","おとこ"], + "🦹♀️": ["じょせいのあくとう","くうそう","あく","はんざい","あくじ","ちょうたいこく","あくやく","じょせい","おんな"], + "🦹": ["あくとう","くうそう","あく","はんざい","あくじ","ちょうたいこく","あくやく"], + "🦹♂️": ["だんせいのあくとう","くうそう","あく","はんざい","あくじ","ちょうたいこく","あくやく","だんせい","おとこ"], + "🧙♀️": ["じょせいのまほうつかい","くうそう","まじょ","おんなのまほうつかい","じょせい","おんな"], + "🧙": ["まほうつかい","くうそう","まじゅつし","おとこのまほうつかい"], + "🧙♂️": ["だんせいのまほうつかい","くうそう","まじゅつし","おとこのまほうつかい","だんせい","おとこ"], + "🧝♀️": ["じょせいのこども","くうそう","こども","さきのとがったみみ","じょせい","おんな"], + "🧝": ["こども","くうそう","さきのとがったみみ"], + "🧝♂️": ["だんせいのこども","くうそう","こども","さきのとがったみみ","だんせい","おとこ"], + "🧚♀️": ["じょせいのようせい","くうそう","てぃたーにあ","うぃんぐす","じょせい","おんな"], + "🧚": ["ようせい","くうそう","てぃたーにあ","うぃんぐす"], + "🧚♂️": ["だんせいのようせい","くうそう","おべろん","しょうようせい","だんせい","おとこ"], + "🧞♀️": ["じょせいのせいれい","くうそう","せいれい","じょせい","おんな"], + "🧞": ["せいれい","くうそう"], + "🧞♂️": ["だんせいのせいれい","くうそう","せいれい","だんせい","おとこ"], + "🧜♀️": ["じょせいのにんぎょ","くうそう","じょせい","おんな"], + "🧜": ["にんぎょ","くうそう"], + "🧜♂️": ["だんせいのにんぎょ","くうそう","にんぎょ","だんせい","おとこ"], + "🧌": ["つり","おとぎばなし","ふぁんたじ","もんすたー"], + "🧛♀️": ["じょせいのきゅうけつき","くうそう","あんでっど","じょせい","おんな"], + "🧛": ["きゅうけつき","くうそう","どらきゅら","あんでっど"], + "🧛♂️": ["だんせいのきゅうけつき","くうそう","どらきゅら","あんでっど","だんせい","おとこ"], + "🧟♀️": ["じょせいのぞんび","くうそう","あんでっど","じょせい","おんな"], + "🧟": ["ぞんび","くうそう","あんでっど"], + "🧟♂️": ["だんせいのぞんび","くうそう","あんでっど","だんせい","おとこ"], + "🙇♀️": ["ふかくおじぎするじょせい","しゃざい","おじぎ","じぇすちゃー","ごめんなさい","じょせい","おんな"], + "🙇": ["ふかくおじぎしたひと","しゃざい","おじぎ","じぇすちゃー","ごめんなさい"], + "🙇♂️": ["ふかくおじぎするだんせい","しゃざい","おじぎ","じぇすちゃー","ごめんなさい","おとこ","だんせい"], + "💁♀️": ["あんないするじょせい","て","たすけ","じょうほう","ずうずうしい","じょせい","おんな"], + "💁": ["あんないするひと","て","たすけ","じょうほう","ずうずうしい","じょせい","おんな"], + "💁♂️": ["あんないするだんせい","て","たすけ","じょうほう","ずうずうしい","おとこ","だんせい"], + "🙅♀️": ["NGさいんのじょせい","きんじる","じぇすちゃー","て","だめ","きんし","じょせい","おんな"], + "🙅": ["NGさいんのひと","きんじる","じぇすちゃー","て","だめ","きんし"], + "🙅♂️": ["NGさいんのだんせい","きんじる","じぇすちゃー","て","だめ","きんし","おとこ","だんせい"], + "🙆♀️": ["OKさいんのじょせい","じぇすちゃー","て","ok","じょせい","おんな"], + "🙆": ["OKさいんのひと","じぇすちゃー","て","OK"], + "🙆♂️": ["OKさいんのだんせい","じぇすちゃー","て","ok","おとこ","だんせい"], + "🤷♀️": ["かたをすくめるじょせい","うたがい","むち","むかんしん","かたをすくめる","じょせい","おんな"], + "🤷": ["かたをすくめるひと","うたがい","むち","むかんしん","かたをすくめる"], + "🤷♂️": ["かたをすくめるだんせい","うたがい","むち","むかんしん","かたをすくめる","おとこ","だんせい"], + "🙋♀️": ["かたてをあげてよろこぶじょせい","じぇすちゃー","て","しあわせ","あげる","じょせい","おんな"], + "🙋": ["かたてをあげてよろこぶひと","じぇすちゃー","て","しあわせ","あげる"], + "🙋♂️": ["かたてをあげてよろこぶだんせい","じぇすちゃー","て","しあわせ","あげる","おとこ","だんせい"], + "🤦♀️": ["かおをおさえるじょせい","ふしん","ふんがい","かお","てのひら","じょせい","おんな"], + "🤦": ["てのひらをかおにあてるひと","ふしん","ふんがい","かお","てのひら"], + "🤦♂️": ["がおをおさえるだんせい","ふしん","ふんがい","かお","てのひら","おとこ","だんせい"], + "🧏♀️": ["みみがふじゆうなじょせい","あくせしびりてぃ","みみがふじゆう","じょせい","おんな"], + "🧏": ["みみがふじゆうなひと","あくせしびりてぃ","みみがふじゆう"], + "🧏♂️": ["みみがふじゆうなだんせい","あくせしびりてぃ","みみがふじゆう","だんせい","おとこ"], + "🙎♀️": ["ふくれっつらのじょせい","じぇすちゃー","ふくれっつら","じょせい","おんな"], + "🙎": ["おこったかおのひと","じぇすちゃー","ふくれっつら"], + "🙎♂️": ["ふくれっつらのだんせい","じぇすちゃー","ふくれっつら","おとこ","だんせい"], + "🙍♀️": ["がおをしかめたじょせい","しかめめん","じぇすちゃー","かなしい","じょせい","おんな"], + "🙍": ["ふまんなかおのひと","しかめめん","じぇすちゃー","かなしい"], + "🙍♂️": ["がおをしかめただんせい","しかめめん","じぇすちゃー","かなしい","だんせい","おとこ"], + "💇♀️": ["かみをきられているじょせい","りはつし","びようし","びよう","さんぱつ","へあかっと","びよういん","じょせい","おんな"], + "💇": ["かみをきられているひと","りはつし","びようし","びよう","さんぱつ","へあかっと","びよういん"], + "💇♂️": ["かみをきられているだんせい","りはつし","びようし","びよう","さんぱつ","へあかっと","びよういん","おとこ","だんせい"], + "💆♀️": ["ふぇいすまっさーじをうけるじょせい","まっさーじ","さろん","じょせい","おんな"], + "💆": ["ふぇいすまっさーじをうけるひと","まっさーじ","さろん"], + "💆♂️": ["ふぇいすまっさーじをうけるだんせい","まっさーじ","さろん","おとこ","だんせい"], + "🤰": ["にんぷ","にんしん","あかちゃん","じょせい","おんな","はら","ふくれた","ふっくらした"], + "🫄": ["にんしんしたひと","はら","ふくれた","ふっくらした","にんしん","あかちゃん"], + "🫃": ["にんしんしているだんせい","はら","ふくれた","ふっくらした","にんしん","あかちゃん","だんせい","おとこ"], + "🤱": ["ぼにゅう","むね","あかちゃん","あかんぼう","にゅうじ","ようじ","はは","こども","ほいく","みるく","じょせい","おんな"], + "👩🍼": ["あかちゃんにごはんをあげるじょせい","あかちゃん","にゅうじ","こども","じゅにゅう","みるく","ぼとる","じょせい","おんな"], + "🧑🍼": ["あかちゃんにごはんをあげるひと","あかちゃん","にゅうじ","こども","じゅにゅう","みるく","ぼとる"], + "👨🍼": ["あかちゃんにごはんをあげるだんせい","あかちゃん","にゅうじ","こども","じゅにゅう","みるく","ぼとる","だんせい","おとこ"], + "🧎♀️": ["ひざたちしているじょせい","ひざ","ひざたち","じょせい","おんな"], + "🧎": ["ひざたちしているひと","ひざ","ひざたち"], + "🧎♂️": ["ひざたちしているだんせい","ひざ","ひざたち","だんせい","おとこ"], + "🧍♀️": ["たっているじょせい","たつ","すたんでぃんぐ","じょせい","おんな"], + "🧍": ["たっているひと","たつ","すたんでぃんぐ"], + "🧍♂️": ["たっているだんせい","たつ","すたんでぃんぐ","だんせい","おとこ"], + "🚶♀️": ["あるくじょせい","はいきんぐ","ほこうしゃ","あるく","うぉーきんぐ","じょせい","おんな"], + "🚶": ["あるくひと","はいきんぐ","ほこうしゃ","あるく","うぉーきんぐ"], + "🚶♂️": ["あるくだんせい","はいきんぐ","ほこうしゃ","あるく","うぉーきんぐ","おとこ","だんせい"], + "👩🦯": ["しろつえをもったじょせい","あくせしびりてぃ","めがふじゆう","じょせい","おんな"], + "🧑🦯": ["しろつえをもったひと","あくせしびりてぃ","めがふじゆう"], + "👨🦯": ["しろつえをもっただんせい","あくせしびりてぃ","めがふじゆう","だんせい","おとこ"], + "🏃♀️": ["はしるじょせい","まらそん","らんなー","らんにんぐ","じょせい","おんな"], + "🏃": ["はしるひと","まらそん","らんなー","らんにんぐ"], + "🏃♂️": ["はしるだんせい","まらそん","らんなー","らんにんぐ","おとこ","だんせい"], + "👩🦼": ["でんどうくるまいすにすわっているじょせい","あくせしびりてぃ","くるまいす","じょせい","おんな"], + "🧑🦼": ["でんどうくるまいすにすわっているひと","あくせしびりてぃ","くるまいす"], + "👨🦼": ["でんどうくるまいすにすわっているだんせい","あくせしびりてぃ","くるまいす","だんせい","おとこ"], + "👩🦽": ["しゅどうくるまいすにすわっているじょせい","あくせしびりてぃ","くるまいす","じょせい","おんな"], + "🧑🦽": ["しゅどうくるまいすにすわっているひと","あくせしびりてぃ","くるまいす"], + "👨🦽": ["しゅどうくるまいすにすわっているだんせい","あくせしびりてぃ","くるまいす","だんせい","おとこ"], + "💃": ["じょせいだんさー","だんす","おどる","だんさー","じょせい","おんな"], + "🕺": ["だんせいだんさー","だんす","おどる","だんさー","おとこ","だんせい"], + "👯♀️": ["ばにーがーる","うさぎみみ","だんさー","じょせい","おんな"], + "👯": ["うさぎみみのひと","うさぎみみ","だんさー"], + "👯♂️": ["うさぎみみのだんせい","うさぎみみ","だんさー","おとこ","だんせい"], + "👫": ["しゅをつないだだんじょ","かっぷる","て","つなぐ","おとこ","おんな","だんじょ"], + "👭": ["しゅをつないだじょせい","かっぷる","て","つなぐ","じょせい","おんな","ぷらいど","lgbt","れずびあん"], + "👬": ["しゅをつないだだんせい","かっぷる","て","つなぐ","だんせい","おとこ","ぷらいど","lgbt","げい"], + "🧑🤝🧑": ["しゅをつないだひとたち","かっぷる","て","にぎる"], + "👩❤️👨": ["はーとのかっぷる (じょせい、だんせい)","かっぷる","はーと","あい","れんあい","おとこ","おんな","だんじょ"], + "👩❤️👩": ["はーとのかっぷる (じょせい、じょせい)","かっぷる","はーと","あい","れんあい","じょせい","おんな","ぷらいど","lgbt","れずびあん"], + "💑": ["はーとのかっぷる","かっぷる","はーと","あい","れんあい","おとこ","おんな","だんじょ"], + "👨❤️👨": ["はーとのかっぷる (だんせい、だんせい)","かっぷる","はーと","あい","れんあい","だんせい","おとこ","ぷらいど","lgbt","げい"], + "👩❤️💋👨": ["きす (じょせい、だんせい)","かっぷる","きす","はーと","あい","れんあい","おとこ","おんな","だんじょ"], + "👩❤️💋👩": ["きす (じょせい、じょせい)","かっぷる","きす","はーと","あい","れんあい","じょせい","おんな","ぷらいど","lgbt","げい"], + "💏": ["きす","かっぷる","はーと","あい","れんあい","おとこ","おんな","だんじょ"], + "👨❤️💋👨": ["きす (だんせい、だんせい)","かっぷる","きす","はーと","あい","れんあい","だんせい","おとこ","ぷらいど","lgbt","げい"], + "👪": ["かぞく","ちちおや","ははおや","おとこ","おんな","だんじょ","おとこのこ","こども"], + "👨👩👧": ["かぞく (だんせい、じょせい、おんなのこ)","ちちおや","ははおや","おとこ","おんな","だんじょ","おんなのこ","こども"], + "👨👩👧👦": ["かぞく (だんせい、じょせい、おんなのこ、おとこのこ)","ちちおや","ははおや","おとこ","おんな","だんじょ","おとこのこ","おんなのこ","こども"], + "👨👩👦👦": ["かぞく (だんせい、じょせい、おとこのこ、おとこのこ)","ちちおや","ははおや","おとこ","おんな","だんじょ","おとこのこ","こども"], + "👨👩👧👧": ["かぞく (だんせい、じょせい、おんなのこ、おんなのこ)","ちちおや","ははおや","おとこ","おんな","だんじょ","おんなのこ","こども"], + "👩👩👦": ["かぞく (じょせい、じょせい、おとこのこ)","かぞく","ははおや","じょせい","おんな","おとこのこ","こども","ぷらいど","lgbt","れずびあん"], + "👩👩👧": ["かぞく (じょせい、じょせい、おんなのこ)","かぞく","ははおや","じょせい","おんな","おんなのこ","こども","ぷらいど","lgbt","れずびあん"], + "👩👩👧👦": ["かぞく (じょせい、じょせい、おんなのこ、おとこのこ)","かぞく","ははおや","じょせい","おんな","おとこのこ","おんなのこ","こども","ぷらいど","lgbt","れずびあん"], + "👩👩👦👦": ["かぞく (じょせい、じょせい、おとこのこ、おとこのこ)","かぞく","ははおや","じょせい","おんな","おとこのこ","こども","ぷらいど","lgbt","れずびあん"], + "👩👩👧👧": ["かぞく (じょせい、じょせい、おんなのこ、おんなのこ)","かぞく","ははおや","じょせい","おんな","おんなのこ","こども","ぷらいど","lgbt","れずびあん"], + "👨👨👦": ["かぞく (だんせい、だんせい、おとこのこ)","かぞく","ちちおや","だんせい","おとこ","おとこのこ","こども","ぷらいど","lgbt","げい"], + "👨👨👧": ["かぞく (だんせい、だんせい、おんなのこ)","かぞく","ちちおや","だんせい","おとこ","おんなのこ","こども","ぷらいど","lgbt","げい"], + "👨👨👧👦": ["かぞく (だんせい、だんせい、おんなのこ、おとこのこ)","かぞく","ちちおや","だんせい","おとこ","おとこのこ","おんなのこ","こども","ぷらいど","lgbt","げい"], + "👨👨👦👦": ["かぞく (だんせい、だんせい、おとこのこ、おとこのこ)","かぞく","ちちおや","だんせい","おとこ","おとこのこ","こども","ぷらいど","lgbt","げい"], + "👨👨👧👧": ["かぞく (だんせい、だんせい、おんなのこ、おんなのこ)","かぞく","ちちおや","だんせい","おとこ","おんなのこ","こども","ぷらいど","lgbt","げい"], + "👩👦": ["かぞく(じょせい、おとこのこ)","かぞく","ははおや","じょせい","おんな","おとこのこ","こども"], + "👩👧": ["かぞく(じょせい、おんなのこ)","かぞく","ははおや","じょせい","おんな","おんなのこ","こども"], + "👩👧👦": ["かぞく(じょせい、おんなのこ、おとこのこ)","かぞく","ははおや","じょせい","おんな","だんせい","おんなのこ","おとこのこ","こども"], + "👩👦👦": ["かぞく(じょせい、おとこのこ、おとこのこ)","かぞく","ははおや","じょせい","おんな","おとこのこ","こども"], + "👩👧👧": ["かぞく(じょせい、おんなのこ、おんなのこ)","かぞく","ははおや","じょせい","おんな","おんなのこ","こども"], + "👨👦": ["かぞく(だんせい、おとこのこ)","ちちおや","おとこ","だんせい","おとこのこ","こども"], + "👨👧": ["かぞく(だんせい、おんなのこ)","ちちおや","おとこ","だんじょ","おんなのこ","こども"], + "👨👧👦": ["かぞく(だんせい、おんなのこ、おとこのこ)","ちちおや","おとこ","だんせい","おとこのこ","おんなのこ","こども"], + "👨👦👦": ["かぞく(だんせい、おとこのこ、おとこのこ)","ちちおや","おとこ","だんせい","おとこのこ","こども"], + "👨👧👧": ["かぞく(だんせい、おんなのこ、おんなのこ)","ちちおや","おとこ","だんじょ","おんなのこ","こども"], + "👚": ["れでぃーすうぇあ","ふく","じょせい","おんな"], + "👕": ["てぃーしゃつ","ふく","しゃつ"], + "🥼": ["はくい","ふく","いしゃ","じっけん","かがくしゃ"], + "🦺": ["あんぜんべすと","きんきゅう","あんぜん","べすと"], + "🧥": ["こーと","ふく","じゃけっと"], + "👖": ["じーんず","ふく","ぱんつ","ずぼん"], + "👔": ["ねくたい","ふく"], + "👗": ["どれす","ふく"], + "👘": ["きもの","ふく","わふく"], + "🥻": ["さりー","ふく","どれす"], + "🩱": ["わんぴーす","ふく","みずぎ","すいみんぐうぇあ","すいえい"], + "👙": ["びきに","ふく","すいえい"], + "🩲": ["ぶりーふ","ふく","みずぎ","すいみんぐうぇあ","すいえい","したぎ"], + "🩳": ["しょーつ","ふく","みずぎ","すいみんぐうぇあ","すいえい","したぎ"], + "💄": ["くちべに","けしょうひん","こすめ","けしょう","めいく"], + "💋": ["きすまーく","はーと","きす","くちびる","まーく","れんあい","ろまんす"], + "👣": ["あしあと","からだ","ふく"], + "🧦": ["くつした","ふく","そっくす","いちくみ"], + "🩴": ["ごむせいさんだる","びーち","さんだる","ぞうり"], + "👠": ["はいひーる","ふく","ひーる","くつ","じょせい","おんな"], + "👡": ["れでぃーすさんだる","ふく","さんだる","くつ","じょせい","おんな"], + "👢": ["れでぃーすぶーつ","ぶーつ","ふく","くつ","じょせい","おんな"], + "🥿": ["れでぃーすふらっとしゅーず","ふく","ばれえふらっと","すりっぽん","すりっぱ"], + "👞": ["めんずしゅーず","ふく","だんせい","おとこ","くつ"], + "👟": ["うんどうくつ","うんどう","ふく","しゅーず","すにーかー"], + "🩰": ["ばれえしゅーず","ふく","しゅーず","ばれえ","だんす"], + "🥾": ["はいきんぐぶーつ","ふく","ばっくぱっく","ぶーつ","きゃんぷ","はいきんぐ"], + "🧢": ["きゃっぷ","ふく","やきゅう","はっと","ぼうし"], + "👒": ["れでぃーすはっと","ふく","ぼうし","じょせい","おんな"], + "🎩": ["しるくはっと","あくてぃびてぃ","ふく","えんたーていんめんと","ごらく","ぼうし","とっぷす"], + "🎓": ["そつぎょうしきのかくぼう","あくてぃびてぃ","ぼうし","おいわい","ふく","そつぎょう","はっと"], + "👑": ["かんむり","ふく","おうかん","おう","じょおう"], + "⛑": ["しろじゅうじのへるめっと","きゅうじょ","じゅうじ","かお","ぼうし","へるめっと"], + "🪖": ["ぐんたいのへるめっと","ぐん","へるめっと","ぐんたい","ぐんじん","へいし"], + "🎒": ["らんどせる","あくてぃびてぃ","かばん","ばっぐ","がくせいかばん","がっこう"], + "👝": ["ぽーち","かばん","ばっぐ","ふく"], + "👛": ["さいふ","ふく","こいん"], + "👜": ["はんどばっぐ","かばん","ばっぐ","ふく"], + "💼": ["ぶりーふけーす"], + "👓": ["めがね","ふく","め","あいうぇあ"], + "🕶": ["さんぐらす","くらい","め","めがね"], + "🥽": ["ごーぐる","ふく","めのほご","すいえい","ようせつ"], + "🧣": ["すかーふ","ふく","くび"], + "🧤": ["てぶくろ","ふく","て"], + "💍": ["ゆびわ","だいやもんど","れんあい","ろまんす"], + "🌂": ["とじたかさ","ふく","あめ","かさ","てんき"], + "☂": ["かさ","ふく","あめ","てんき"], + "🐶": ["いぬのかお","けん","いぬ","かお","ぺっと"], + "🐱": ["ねこのかお","ねこ","かお","ぺっと"], + "🐭": ["ねずみのかお","かお","ねずみ"], + "🐹": ["はむすたーのかお","かお","はむすたー","ぺっと"], + "🐰": ["うさぎのかお","ばにー","かお","ぺっと","うさぎ"], + "🐻": ["くまのかお","くま","かお"], + "🧸": ["てでぃべあ","おもちゃ","びろーど","ぬいぐるみ"], + "🐼": ["ぱんだのかお","かお","ぱんだ","くま"], + "🐻❄️": ["しろくま","かお","ほっきょく","くま","しろ"], + "🐨": ["こあら","くま","ゆうぶくろるい","おーすとらりあ"], + "🐯": ["とらのかお","かお","とら"], + "🦁": ["らいおんのかお","かお","ししざ","らいおん","せいざ"], + "🐮": ["うしのかお","うし","かお"], + "🐷": ["ぶたのかお","かお","ぶた"], + "🐽": ["ぶたのはな","かお","はな","ぶた"], + "🐸": ["かえるのかお","かお","かえる"], + "🐵": ["さるのかお","かお","さる"], + "🙈": ["みざる","わるい","かお","きんじる","じぇすちゃー","さる","だめ","きんし","みる"], + "🙉": ["きかざる","わるい","かお","きんじる","じぇすちゃー","きく","さる","ない","なし","きんし"], + "🙊": ["いわざる","わるい","かお","きんじる","じぇすちゃー","さる","ない","なし","きんし","はなす"], + "🐒": ["さる"], + "🦍": ["ごりら"], + "🦧": ["おらんうーたん","るいじんえん"], + "🐔": ["にわとり"], + "🐧": ["ぺんぎん"], + "🐦": ["とり"], + "🐦⬛": ["くろいとり","とり","くろ","からす","わたりがらす","みやまがらす"], + "🐤": ["ひよこ","あかちゃん"], + "🐣": ["ひよこ","あかちゃん","ふか"], + "🐥": ["しょうめんをむいたひよこ","あかちゃん","ひよこ"], + "🐺": ["おおかみのかお","かお","おおかみ"], + "🦊": ["きつねのかお","かお","きつね"], + "🦝": ["あらいぐま","かお","こうきしんがつよい","ずるかしこい"], + "🐗": ["いのしし","ぶた"], + "🐴": ["うまのかお","かお","うま"], + "🦓": ["しまうま","かお"], + "🦒": ["きりん","かお"], + "🦌": ["しか"], + "🫎": ["へらじか","どうぶつ","えだつの","えるく","ほにゅうるい"], + "🦘": ["かんがるー","おーすとらりあ","じゃんぷ","ゆうぶくろるい"], + "🦥": ["たいだ","なまける","おそい"], + "🦦": ["かわうそ","づり","ふざける"], + "🦫": ["びーばー","だむ"], + "🦄": ["ゆにこーんのかお","かお","ゆにこーん"], + "🐝": ["みつばち","はち","こんちゅう"], + "🐛": ["むし","こんちゅう"], + "🦋": ["ちょう","こんちゅう","うつくしい"], + "🐌": ["かたつむり"], + "🪲": ["かぶとむし","むし","こんちゅう"], + "🐞": ["てんとうむし","かぶとむし","こんちゅう","てんとうちゅう"], + "🐜": ["あり","こんちゅう"], + "🦗": ["くりけっと","こおろぎ","ばっため","こんちゅう"], + "🪳": ["ごきぶり","こんちゅう","がいちゅう"], + "🕷": ["くも","こんちゅう"], + "🕸": ["くものす","くも","す"], + "🦂": ["さそり","さそりざ","せいざ"], + "🦟": ["か","びょうき","ねつ","こんちゅう","まらりあ","ういるす"], + "🪰": ["はえ","がいちゅう","こんちゅう","うじむし"], + "🪱": ["ぜんちゅう","たまきがたどうぶつ","みみず","きせいちゅう"], + "🦠": ["びせいぶつ","あめーば","ばくてりあ","ういるす"], + "🐢": ["かめ"], + "🐍": ["へび","うんぱんにん","へびつかいざ","せいざ"], + "🦎": ["とかげ","はちゅうるい"], + "🐙": ["たこ"], + "🦑": ["いか","なんたいどうぶつ"], + "🪼": ["くらげ","くすり","むせきついどうぶつ","ぜりー","うみ","いたい","しもう"], + "🦞": ["ろぶすたー","びすく","つめ","しーふーど"], + "🦀": ["かに","かにざ","せいざ"], + "🦐": ["えび","かい","ちいさい"], + "🦪": ["かき","しんじゅ","だいびんぐ"], + "🐠": ["ねったいぎょ","さかな","ねったい"], + "🐟": ["さかな","うおざ","せいざ"], + "🐡": ["ふぐ","さかな"], + "🐬": ["いるか","ひれ"], + "🦈": ["さめ","さかな"], + "🦭": ["あざらし","あしか"], + "🐳": ["しおふきくじら","かお","しおふき","くじら"], + "🐋": ["くじら"], + "🐊": ["わに"], + "🐆": ["ひょう"], + "🐅": ["とら"], + "🐃": ["すいぎゅう","みず"], + "🐂": ["ゆううし","おすうし","おうしざ","せいざ"], + "🐄": ["うし"], + "🦬": ["ばいそん","ばっふぁろー","むれ","ヴぃせんと"], + "🐪": ["ひとこぶらくだ","らくだ","こぶ"], + "🐫": ["ふたこぶらくだ","ふたこぶ","らくだ","こぶ"], + "🦙": ["らま","あるぱか","ぐあなこ","びくーにゃ","うーる"], + "🐘": ["ぞう"], + "🦏": ["さい"], + "🦛": ["かば"], + "🦣": ["まんもす","ぜつめつ","おおがた","きば","けにおおわれた"], + "🐐": ["やぎ","やぎざ","せいざ"], + "🐏": ["こひつじ","おひつじざ","ひつじ","せいざ"], + "🐑": ["ひつじ","めすひつじ"], + "🐎": ["うま","けいば","れーす"], + "🫏": ["ろば","どうぶつ","ぶーろ","ほにゅうるい","らば"], + "🐖": ["ぶた","めすぶた"], + "🦇": ["こうもり","きゅうけつき"], + "🐓": ["おんどり"], + "🦃": ["しちめんちょう(とり)","しちめんちょう","とり"], + "🕊": ["へいわのはと","とり","はと","ひこう","へいわ"], + "🦅": ["わし","とり"], + "🦆": ["あひる","とり"], + "🪿": ["がちょう","とり","かきん","けいてきのおと"], + "🦢": ["はくちょう","とり","はくちょうのお","みにくいあひるのこ"], + "🦉": ["ふくろう","とり","かしこい"], + "🦩": ["ふらみんご","ねったい","あざやか"], + "🦚": ["おすのくじゃく","とり","めすのくじゃく"], + "🦜": ["おうむ","とり","かいぞく"], + "🦤": ["どーどー","とり","ぜつめつ"], + "🪽": ["はね","てんし","こうくう","とり","ひこう","しんわ"], + "🪶": ["うもう","とり","かるい","はね"], + "🐕": ["いぬ","けん","ぺっと"], + "🦮": ["もうどうけん","あくせしびりてぃ","めがふじゆう","けん","がいど"], + "🐕🦺": ["かいじょいぬ","あくせしびりてぃ","しえん","けん","さーびす"], + "🐩": ["ぷーどる","いぬ","けん"], + "🐈": ["ねこ","ぺっと"], + "🐈⬛": ["くろねこ","くろ","ねこ","ぺっと","はろうぃーん"], + "🐇": ["うさぎ","ばにー","ぺっと"], + "🐀": ["ねずみ"], + "🐁": ["ねずみ"], + "🐿": ["しまりす"], + "🦨": ["すかんく","あくしゅう","におう"], + "🦡": ["あなぐま","らーてる","ねだる"], + "🦔": ["はりねずみ","かお"], + "🐾": ["どうぶつのあしあと","あし","あと"], + "🐉": ["どらごん","おとぎばなし"], + "🐲": ["どらごんのかお","どらごん","かお","おとぎばなし"], + "🦕": ["りゅうあしるい","ぶらきおさうるす","ぶろんとさうるす","でぃぷろどくす","きょうりゅう"], + "🦖": ["てぃらのさうるす","Tれっくす","きょうりゅう"], + "🌵": ["さぼてん","しょくぶつ"], + "🎄": ["くりすますつりー","あくてぃびてぃ","おいわい","くりすます","えんたーていめんと","つりー"], + "🌲": ["じょうりょくじゅ","じょうりょく","しょくぶつ","はた"], + "🌳": ["らくようじゅ","らくようせい","しょくぶつ","らくよう","はた"], + "🌴": ["やしのき","やし","しょくぶつ","はた"], + "🪴": ["はちうえ","しょくぶつ","かんようしょくぶつ"], + "🌱": ["なえぎ","しょくぶつ","わかい"], + "🌿": ["はーぶ","は","しょくぶつ"], + "☘": ["くろーばー","しょくぶつ"], + "🍀": ["よっつはのくろーばー","4","くろーばー","よん","は","しょくぶつ"], + "🎍": ["かどまつ","あくてぃびてぃ","たけ","おいわい","にっぽん","まつ","しょくぶつ"], + "🎋": ["ななゆう","あくてぃびてぃ","はた","おいわい","えんたーていめんと","にっぽん"], + "🍃": ["かぜになびくは","ふく","はためく","は","しょくぶつ","ふう"], + "🍂": ["おちば","らっか","は","しょくぶつ"], + "🍁": ["かえでのは","らっか","は","かえで","しょくぶつ"], + "🌾": ["いなほ","いねたば","ほ","しょくぶつ","こめ"], + "🪺": ["たまごのあるす","すづくり","とりのす","たまご"], + "🪹": ["そらのす","すづくり","とりのす"], + "🌺": ["はいびすかす","はな","しょくぶつ"], + "🌻": ["ひまわり","はな","しょくぶつ","たいよう"], + "🌹": ["ばら","はな","しょくぶつ"], + "🥀": ["しおれたはな","はな","しおれた"], + "🌷": ["ちゅーりっぷ","はな","しょくぶつ"], + "🌼": ["はな","しょくぶつ"], + "🌸": ["さくら","はな","しょくぶつ"], + "🪷": ["はす","ぶっきょう","はな","ひんどぅーきょう","いんど","せいじょう","べとなむ"], + "🪻": ["ひあしんす","ぶるーぼんねっと","はな","らべんだー","るぴなす","のうるーず","むらさき","きんぎょそう"], + "💐": ["はなたば","はな","しょくぶつ","ろまんす"], + "🍄": ["きのこ","しょくぶつ"], + "🐚": ["まきがい","かい"], + "🪸": ["さんご","たいよう","しょう"], + "🌎": ["あめりかたいりく","あめりか","ちきゅう","せかい"], + "🌍": ["よーろっぱとあふりかちいき","あふりか","ちきゅう","よーろっぱ","せかい"], + "🌏": ["あじあとおーすとらりあ","あじあ","おーすとらりあ","ちきゅう","せかい"], + "🌕": ["まんげつ","つき","うちゅう","てんき"], + "🌖": ["ねまちのつき","じゅうさんや","つき","うちゅう","かけ","てんき"], + "🌗": ["かげんのつき","つき","げん","うちゅう","てんき"], + "🌘": ["かけていくみかづき","さんじつげつ","つき","うちゅう","かけ","てんき"], + "🌑": ["しんげつ","かい","つき","うちゅう","てんき"], + "🌒": ["みちていくみかづき","さんじつげつ","つき","うちゅう","じょうげん","てんき"], + "🌓": ["じょうげんのつき","つき","げん","うちゅう","てんき"], + "🌔": ["じゅうさんやつき","じゅうさんや","つき","うちゅう","じょうげん","てんき"], + "🌙": ["さんじつげつ","つき","うちゅう","てんき"], + "🌚": ["かおつきしんげつ","かお","つき","うちゅう","てんき"], + "🌝": ["かおつきまんげつ","あかるい","かお","みちた","つき","うちゅう","てんき"], + "🌛": ["かおつきじょうげんのつき","かお","つき","げん","うちゅう","てんき"], + "🌜": ["がおがあるかげんのつき","かお","つき","げん","うちゅう","てんき"], + "⭐": ["ちゅうくらいのほし","ほし"], + "🌟": ["ひかるほし","きらめき","あかいひかり","かがやく","かがやき","ほし"], + "💫": ["くらくら","まんが","めまい","ほし"], + "✨": ["きらきら","えんたーていめんと","かがやき","ほし"], + "☄": ["すいせい","うちゅう"], + "🪐": ["たまきのあるわくせい","うちゅう","わくせい","どせい"], + "🌞": ["かおつきたいよう","あかるい","かお","うちゅう","たいよう","てんき"], + "☀️": ["たいようのひかり","あかるい","こうせん","うちゅう","たいよう","せいてん","てんき"], + "🌤": ["たいようとちいさなくも","くも","たいよう","てんき"], + "⛅": ["はれときどきくもり","くも","たいよう","てんき"], + "🌥": ["はれのちくもり","くも","たいよう","てんき"], + "🌦": ["はれのちくもりときどきあめ","くも","あめ","たいよう","てんき"], + "☁️": ["くも","てんき"], + "🌧": ["あまぐも","くも","あめ","てんき"], + "⛈": ["らいう","くも","あめ","かみなり","てんき"], + "🌩": ["らいうん","くも","かみなり","てんき"], + "⚡": ["だかでんあつきごう","きけん","でんき","かみなり","でんあつ","びりびり"], + "🔥": ["えん","ひ","どうぐ"], + "💥": ["しょうとつまーく","どかーん","しょうとつ","まんが"], + "❄️": ["せつのけっしょう","つめたい","ゆき","てんき"], + "🌨": ["ゆきぐも","くも","れい","ゆき","てんき"], + "☃": ["ゆきだるま","れい","ゆき","てんき"], + "⛄": ["ゆきだるま","れい","ゆき","てんき"], + "🌬": ["かぜがふいている","かぜがふく","くも","かお","てんき","ふう"], + "💨": ["だっしゅ","まんが","はしる"], + "🌪": ["たつまきぐも","くも","たつまき","てんき","せんぷう"], + "🌫": ["きり","くも","てんき"], + "🌈": ["にじ","あめ","れいんぼー","てんき","ぷらいど","lgbt"], + "☔": ["うとかさ","いるい","しずく","あめ","かさ","てんき"], + "💧": ["しずく","ぞっとする","まんが","したたり","あせ","てんき"], + "💦": ["あせまーく","まんが","ぬれている","あせ"], + "🌊": ["なみ","うみ","みず","てんき"], + "🍏": ["あおりんご","りんご","ふるーつ","くだもの","みどり","しょくぶつ"], + "🍎": ["あかいりんご","りんご","ふるーつ","くだもの","しょくぶつ","あか"], + "🍐": ["なし","ふるーつ","くだもの","しょくぶつ"], + "🍊": ["みかん","ふるーつ","くだもの","おれんじ","しょくぶつ","あかだいだいいろ"], + "🍋": ["れもん","かんきつるい","ふるーつ","くだもの","しょくぶつ"], + "🍌": ["ばなな","ふるーつ","くだもの","しょくぶつ"], + "🍉": ["すいか","ふるーつ","くだもの","しょくぶつ"], + "🍇": ["ぶどう","ふるーつ","くだもの","しょくぶつ"], + "🍓": ["いちご","べりー","ふるーつ","くだもの","しょくぶつ"], + "🍈": ["めろん","ふるーつ","くだもの","しょくぶつ"], + "🍒": ["さくらんぼ","ふるーつ","くだもの","しょくぶつ"], + "🫐": ["ぶるーべりー","べりー","びるべりー","あお","ふるーつ"], + "🍑": ["もも","ふるーつ","くだもの","しょくぶつ"], + "🥭": ["まんごー","ねったい","ふるーつ"], + "🍍": ["ぱいなっぷる","ふるーつ","くだもの","しょくぶつ"], + "🥥": ["ここなっつ","ふるーつ"], + "🥝": ["きういふるーつ","ふるーつ","くだもの","きうい"], + "🍅": ["とまと","しょくぶつ","やさい"], + "🥑": ["あぼかど","ふるーつ","くだもの"], + "🫒": ["おりーぶ","ふるーつ"], + "🍆": ["なす","なすび","しょくぶつ","やさい"], + "🌶": ["とうがらし","からい","こしょう","しょくぶつ"], + "🫑": ["ぴーまん","とうがらし","こしょう","しょくぶつ","やさい"], + "🥒": ["きゅうり","ぴくるす","やさい"], + "🥬": ["はっぱのみどり","ちんげんさい","きゃべつ","けーる","れたす"], + "🥦": ["ぶろっこりー","やさい"], + "🫛": ["えんどうまめのさや","まめ","えだまめ","まめか","えんどうまめ","さや","やさい"], + "🧄": ["にんにく","やさい","しょくぶつ","こうみりょう"], + "🧅": ["たまねぎ","やさい","しょくぶつ","こうみりょう"], + "🌽": ["とうもろこし","こーん","しょくぶつ"], + "🥕": ["にんじん","やさい"], + "🥗": ["ぐりーんさらだ","みどり","さらだ"], + "🥔": ["じゃがいも","やさい"], + "🍠": ["やきいも","じゃがいも","やき","すいーつ"], + "🌰": ["くり","しょくぶつ"], + "🥜": ["ぴーなっつ","なっつ","やさい"], + "🫘": ["まめ","たべもの","じんぞう"], + "🍯": ["はにーぽっと","はちみつ","ぽっと","すいーつ"], + "🍞": ["ぱん","ろーふ"], + "🥐": ["くろわっさん","ぱん","さんじつげつ","ろーる","ふれんち"], + "🥖": ["ふらんすぱん","ぱん","ふれんち"], + "🫓": ["ふらっとぶれっど","あれぱ","らヴぁしゅ","なん","ぴた"], + "🥨": ["ぷれっつぇる","そふとぷれっつぇる","ぷれっつぇるついすと","ぱん"], + "🥯": ["べーぐる","ぱん","くりーむちーず","ひとぬり"], + "🥞": ["ぱんけーき","くれーぷ","ほっとけーき"], + "🧇": ["わっふる","ほっとけーき"], + "🧀": ["ちーず"], + "🍗": ["たーきー","ほね","にわとり","あし","かきん"], + "🍖": ["ほねつきにく","ほね","にく"], + "🥩": ["いちきれのにく","にく","きりみ","らむちょっぷ","ぶた","すてーき"], + "🍤": ["えびふらい","ふらい","えび","こえび","てんぷら"], + "🥚": ["たまご"], + "🍳": ["りょうり","たまご","ふらいぱん","なべ"], + "🥓": ["べーこん","にく"], + "🍔": ["はんばーがー","ばーがー"], + "🍟": ["ふらいどぽてと","ふらいど","ぽてと"], + "🌭": ["ほっとどっぐ","ふらんくふるとそーせーじ","ほっとどっぐそーせーじ","そーせーじ","うぃんなー","れっどほっと"], + "🍕": ["ぴざ","ちーず","1まい"], + "🍝": ["すぱげってぃ","ぱすた"], + "🥪": ["さんどうぃっち","ぱん","やさい","ちーず","にく","でり"], + "🌮": ["たこす","めきしこ"], + "🌯": ["ぶりとー","めきしこ"], + "🫔": ["たまーれ","たまーり","めきしかん","つつまれた"], + "🥙": ["ふらっとぶれっどさんど","ふぁらふぇる","ふらっとぶれっど","じゃいろ","けばぶ","つめもの"], + "🧆": ["ふぁらふぇる","ひよこまめ"], + "🍜": ["どんぶり","めん","らーめん","むしかねつ","すーぷ"], + "🥘": ["ぱえりあ","きゃせろーる","なべ","あさい"], + "🍲": ["なべ","しちゅー"], + "🫕": ["ふぉんでゅ","ちーず","ちょこれーと","ふぉでゅ","とけた","ぽっと","すいす"], + "🥫": ["かんづめ","ほぞんようしょくひん"], + "🫙": ["びん","こうしんりょう","ようき","そら","そーす","ちょぞう"], + "🧂": ["しお","こうしんりょう","しぇーかー"], + "🧈": ["ばたー","にゅうせいひん"], + "🫚": ["しょうが","びーる","ね","すぱいす"], + "🍥": ["なると","こけいのたべもの","さかな","ねりもの"], + "🍣": ["すし"], + "🍱": ["べんとうばこ","べんとう","はこ"], + "🍛": ["かれーらいす","かれー","ごはん"], + "🍙": ["おにぎり","にっぽん","こめ"], + "🍚": ["ごはん","りょうり","こめ"], + "🍘": ["せんべい","こめ"], + "🥟": ["ぎょうざ"], + "🍢": ["おでん","しーふーど","くし","すてぃっく"], + "🍡": ["だんご","でざーと","にっぽん","くし","すてぃっく","すいーつ"], + "🍧": ["かきごおり","でざーと","こおり","すいーつ"], + "🍨": ["あいすくりーむ","くりーむ","でざーと","こおり","すいーつ"], + "🍦": ["そふとくりーむ","くりーむ","でざーと","こおり","あいすくりーむ","そふと","すいーつ"], + "🍰": ["しょーとけーき","けーき","でざーと","ぺいすとりー","すらいす","すいーつ"], + "🎂": ["ばーすでーけーき","たんじょうび","けーき","おいわい","でざーと","ぺいすとりー","すいーつ"], + "🧁": ["かっぷけーき","べーかりー","すいーつ","でざーと","ぺいすとりー"], + "🥧": ["ぱい","でざーと","すいーつ"], + "🍮": ["かすたーど","でざーと","ぷりん","すいーつ"], + "🍭": ["ぺろぺろきゃんでぃー","きゃんでぃ","でざーと","ろりぽっぷきゃんでぃ","すいーつ"], + "🍬": ["あめ","でざーと","すいーつ"], + "🍫": ["ちょこれーと","ばー","でざーと","すいーつ"], + "🍿": ["ぽっぷこーん"], + "🍩": ["どーなつ","でざーと","すいーつ"], + "🍪": ["くっきー","でざーと","あまい"], + "🥠": ["おみくじいりくっきー","ふぉーちゅんくっきー"], + "🥮": ["げっぺい","あき","まつり"], + "☕": ["ほっとどりんく","いんりょう","こーひー","のみもの","あたたかい","じょうき","おちゃ"], + "🍵": ["ゆのみ","いんりょう","かっぷ","のみもの","おちゃ"], + "🫖": ["てぃーぽっと","どりんく","ぽっと","てぃー","けとる"], + "🥣": ["ぼうるとすぷーん","ちょうしょく","しりある","おかゆ","おーとみーる","ぽりっじ","しょっき"], + "🍼": ["ほにゅうびん","あかちゃん","ぼとる","どりんく","みるく"], + "🥤": ["かっぷとすとろー","じゅーす","そーだ","もると","そふとどりんく","みず","しょっき"], + "🧋": ["たぴおかてぃー","ばぶる","みるく","ぱーる","てぃー","ぼば","たぴおか","もみ"], + "🧃": ["いんりょうぼっくす","じゅーす","いんりょう","ぼっくす","どりんく","すとろー"], + "🧉": ["まて","どりんく","ぼんびりや","いえるば"], + "🥛": ["こっぷにはいったぎゅうにゅう","どりんく","ぐらす","みるく"], + "🫗": ["ながれこむえきたい","のみもの","そら","ぐらす","こぼれる"], + "🍺": ["びーる","ばー","のむ","まぐかっぷ"], + "🍻": ["かんぱい","ばー","びーる","かちん","のみもの","まぐかっぷ"], + "🍷": ["わいんぐらす","ばー","いんりょう","のみもの","ぐらす","わいん"], + "🥂": ["ぐらすでかんぱい","いわう","かちん","のみもの","ぐらす"], + "🥃": ["たんぶらー","ぐらす","て","しょっと","ういすきー","うぃすきー","ばーぼん"], + "🍸": ["かくてるぐらす","ばー","かくてる","のみもの","ぐらす"], + "🍹": ["とろぴかるどりんく","ばー","のみもの","とろぴかる"], + "🍾": ["びんととびだすせん","ばー","ぼとる","しゃんぱん","しゃんぺん","しゃんぱーにゅ","こるく","のみもの","とびだす","ぱーてぃー"], + "🍶": ["とっくりとおちょこ","ばー","いんりょう","ぼとる","かっぷ","のみもの","て"], + "🧊": ["かくこおり","こおり","りっぽうたい","つめたい","ひょうざん"], + "🥄": ["すぷーん","しょっき"], + "🍴": ["ふぉーくとないふ","ちょうり","ふぉーく","ないふ","しょっき"], + "🍽": ["ふぉーくとないふとぷれーと","ちょうり","ふぉーく","ないふ","ぷれーと","しょっき"], + "🥢": ["はし"], + "🥡": ["ていくあうとぼっくす","ていくあうと","ようき","おもちかえり"], + "⚽": ["さっかーぼーる","ぼーる","さっかー"], + "🏀": ["ばすけっとぼーる","ぼーる","ばすけっとりんぐ"], + "🏈": ["あめりかんふっとぼーる","あめりかん","ぼーる","ふっとぼーる"], + "⚾": ["やきゅう","ぼーる"], + "🥎": ["そふとぼーる","ぼーる","しあい","すぽーつ"], + "🎾": ["てにすぼーる","ぼーる","らけっと","てにす"], + "🏐": ["ばれーぼーる","ぼーる","しあい"], + "🏉": ["らぐびー","ぼーる","ふっとぼーる"], + "🎱": ["びりやーど","8","えいとぼーる","ぼーる","えいと","げーむ"], + "🥏": ["そらとぶえんばん","でぃすく","あるてぃめっと","ごるふ","しあい","すぽーつ","ふりすびー"], + "🪃": ["ぶーめらん","おーすとらりあ","ぎゃくもどり","はねかえり"], + "🏓": ["たっきゅうのらけっととぼーる","ぼーる","ばっと","しあい","ぱどる","たっきゅう"], + "🏸": ["ばどみんとんのらけっととしゃとる","ばどみんとん","ばーでぃー","しあい","らけっと","しゃとる"], + "🥅": ["ごーるねっと","ごーる","ねっと"], + "🏒": ["あいすほっけーのすてぃっくとぱっく","しあい","ほっけー","こおり","ぱっく","すてぃっく"], + "🏑": ["ふぃーるどほっけーのすてぃっくとぼーる","ぼーる","ふぃーるど","しあい","ほっけー","すてぃっく"], + "🏏": ["くりけっとのばっととぼーる","ぼーる","ふぃーるど","くりけっと","しあい"], + "🥍": ["らくろす","ぼーる","すてぃっく","しあい","すぽーつ"], + "🥌": ["かーりんぐすとーん","かーりんぐ","すとーん"], + "⛳": ["ごるふのかっぷ","ぴんふらっぐ","ごるふ","ほーる"], + "🏹": ["ゆみや","しゃしゅ","や","ゆみ","しゃしゅざ","どうぐ","せいざ"], + "🎣": ["つりざおとさかな","えんたーていめんと","さかな","ぼう"], + "🤿": ["だいびんぐますく","だいびんぐ","すきゅーば","しゅのーける"], + "🥊": ["ぼくしんぐぐろーぶ","ぼくしんぐ","ぐろーぶ"], + "🥋": ["どうぎ","じゅうどう","からて","ぶどう","てこんどー","ゆにふぉーむ"], + "⛸": ["あいすすけーと","こおり"], + "🎿": ["すきーとすきーぶーつ","すきー","ゆき"], + "🛷": ["そり","るーじゅ","とぼがん"], + "⛷": ["すきー","ゆき"], + "🏂": ["すのーぼーだー","すきー","ゆき","すのーぼーど"], + "🏋️♀️": ["うえいとをもちあげるじょせい","あげ","じゅうりょう","じょせい","おんな"], + "🏋": ["うえいとをもちあげるひと","あげ","じゅうりょう"], + "🏋️♂️": ["うえいとをもちあげるだんせい","あげ","じゅうりょう","おとこ","だんせい"], + "🤺": ["ふぇんしんぐをするひと","けんし","けんじゅつ","けん"], + "🤼♀️": ["れすりんぐをするじょせい","れすりんぐ","れすりんぐせんしゅ","じょせい","おんな"], + "🤼": ["れすりんぐをするひとたち","れすりんぐ","れすりんぐせんしゅ"], + "🤼♂️": ["れすりんぐをするだんせい","れすりんぐ","れすりんぐせんしゅ","おとこ","だんせい"], + "🤸♀️": ["そくてんをするじょせい","そくほうてんかい","たいそう","じょせい","おんな"], + "🤸": ["そくてんをするひと","そくほうてんかい","たいそう"], + "🤸♂️": ["そくてんをするだんせい","そくほうてんかい","たいそう","おとこ","だんせい"], + "⛹️♀️": ["ぼーるをばうんどさせるじょせい","ぼーる","じょせい","おんな"], + "⛹": ["ぼーるをばうんどさせるひと","ぼーる"], + "⛹️♂️": ["ぼーるをばうんどさせるだんせい","ぼーる","おとこ","だんせい"], + "🤾♀️": ["はんどぼーるをするじょせい","ぼーる","はんどぼーる","じょせい","おんな"], + "🤾": ["はんどぼーるをするひと","ぼーる","はんどぼーる"], + "🤾♂️": ["はんどぼーるをするだんせい","ぼーる","はんどぼーる","おとこ","だんせい"], + "🧗♀️": ["くらいみんぐしているじょせい","くらいみんぐ","ろっく","じょせい","おんな"], + "🧗": ["くらいみんぐしているひと","くらいみんぐ","ろっく"], + "🧗♂️": ["くらいみんぐしているだんせい","くらいみんぐ","ろっく","だんせい","おとこ"], + "🏌️♀️": ["ごるふをするじょせい","ぼーる","ごるふ","ごるふぁー","ごるふする","じょせい","おんな"], + "🏌": ["ごるふをするひと","ぼーる","ごるふ","ごるふぁー","ごるふする"], + "🏌️♂️": ["ごるふをするだんせい","ぼーる","ごるふ","ごるふぁー","ごるふする","おとこ","だんせい"], + "🧘♀️": ["れんげざのじょせい","めいそう","よが","せいおん","じょせい","おんな"], + "🧘": ["れんげざのひと","めいそう","よが","せいおん"], + "🧘♂️": ["れんげざのだんせい","めいそう","よが","せいおん","だんせい","おとこ"], + "🧖♀️": ["すちーむるーむにいるじょせい","さうな","すちーむるーむ","はまむ","すちーむばす","じょせい","おんな"], + "🧖": ["すちーむるーむにいるひと","さうな","すちーむるーむ","はまむ","すちーむばす"], + "🧖♂️": ["すちーむるーむにいるだんせい","さうな","すちーむるーむ","はまむ","すちーむばす","だんせい","おとこ"], + "🏄♀️": ["さーふぃんをするじょせい","さーふぁー","さーふぃん","なみのり","じょせい","おんな"], + "🏄": ["さーふぃんをするひと","さーふぁー","さーふぃん","なみのり"], + "🏄♂️": ["さーふぃんをするだんせい","さーふぁー","さーふぃん","なみのり","おとこ","だんせい"], + "🏊♀️": ["およぐじょせい","およぐ","すいえい","じょせい","おんな"], + "🏊": ["すいえいをするひと","およぐ","すいえい"], + "🏊♂️": ["およぐだんせい","およぐ","すいえい","おとこ","だんせい"], + "🤽♀️": ["すいきゅうをするじょせい","ぽろ","みず","すいきゅう","じょせい","おんな"], + "🤽": ["すいきゅうをするひと","ぽろ","みず","すいきゅう"], + "🤽♂️": ["すいきゅうをするだんせい","ぽろ","みず","すいきゅう","おとこ","だんせい"], + "🚣♀️": ["ぼーとをこぐじょせい","ぼーと","こぎぶね","のりもの","そうてい","じょせい","おんな"], + "🚣": ["ぼーとをこぐひと","ぼーと","こぎぶね","のりもの","そうてい"], + "🚣♂️": ["ぼーとをこぐだんせい","ぼーと","こぎぶね","のりもの","そうてい","おとこ","だんせい"], + "🏇": ["けいば","うま","きしゅ","きょうそうば"], + "🚴♀️": ["じてんしゃにのるじょせい","じてんしゃ","じてんしゃのり","じてんしゃにのるひと","さいくりすと","じょせい","おんな"], + "🚴": ["じてんしゃにのるひと","じてんしゃ","じてんしゃのり","さいくりすと"], + "🚴♂️": ["じてんしゃにのるだんせい","じてんしゃ","じてんしゃのり","じてんしゃにのるひと","さいくりすと","おとこ","だんせい"], + "🚵♀️": ["まうんてんばいくにのるじょせい","まうんてんばいくらいだー","くろすばいく","じてんしゃ","じてんしゃのり","じてんしゃにのるひと","さいくりすと","やま","じょせい","おんな"], + "🚵": ["まうんてんばいくにのるひと","まうんてんばいくらいだー","くろすばいく","じてんしゃ","じてんしゃのり","じてんしゃにのるひと","やま"], + "🚵♂️": ["まうんてんばいくにのるだんせい","まうんてんばいくらいだー","くろすばいく","じてんしゃ","じてんしゃのり","じてんしゃにのるひと","さいくりすと","やま","おとこ","だんせい"], + "🎽": ["らんにんぐしゃつとたすき","らんにんぐ","たすき","しゃつ"], + "🎖": ["くんしょう","おいわい","めだる","ぐんじ"], + "🏅": ["すぽーつのめだる","めだる"], + "🥇": ["きんめだる","1い","きん","めだる","1","だい1い"], + "🥈": ["ぎんめだる","めだる","2い","ぎん","2","だい2い"], + "🥉": ["どうめだる","どう","めだる","3い","3","だい3い"], + "🏆": ["とろふぃー","しょう"], + "🏵": ["ばらかざり","しょくぶつ"], + "🎗": ["りまいんだーりぼん","おいわい","りまいんだー","りぼん"], + "🎫": ["きっぷ","あくてぃびてぃ","にゅうじょうりょう","えんたーていめんと","ちけっと"], + "🎟": ["にゅうじょうけん","にゅうじょうりょう","えんたーていめんと","ちけっと"], + "🎪": ["さーかすごや","あくてぃびてぃ","さーかす","えんたーていめんと","てんと"], + "🤹♀️": ["じゃぐりんぐをするじょせい","てんびん","じゃぐりんぐ","じょせい","おんな"], + "🤹": ["じゃぐりんぐをするひと","ばらんす","じゃぐりんぐ"], + "🤹♂️": ["じゃぐりんぐをするだんせい","てんびん","じゃぐりんぐ","だんせい","おとこ"], + "🎭": ["ぶたいげいじゅつ","あくてぃびてぃ","げいじゅつ","えんたーていめんと","かめん","ぶたい","しあたー"], + "🎨": ["えのぐぱれっと","あくてぃびてぃ","あーと","えんたーていめんと","びじゅつかん","かいが","ぱれっと"], + "🎬": ["かちんこ","あくてぃびてぃ","えんたーていめんと","えいが"], + "🎤": ["まいく","あくてぃびてぃ","えんたーていめんと","からおけ","まいくろふぉん"], + "🎧": ["へっどほん","あくてぃびてぃ","いやほん","えんたーていめんと","へっどふぉん"], + "🎼": ["がくふ","あくてぃびてぃ","えんたーていめんと","おんがく"], + "🎹": ["けんばん","あくてぃびてぃ","えんたーていめんと","がっき","きーぼーど","おんがく","ぴあの"], + "🪗": ["あこーでぃおん","こんさーてぃーな","すくいーずぼっくす"], + "🥁": ["どらむ","どらむすてぃっく","おんがく"], + "🪘": ["ながいどらむ","びーと","こんが","どらむ","りずむ","じゃんべ"], + "🪇": ["まらかす","いわう","がっき","おんがく","そうおん","だがっき","がたがた","りずむ","しぇいく"], + "🎷": ["さっくす","あくてぃびてぃ","えんたーていめんと","がっき","おんがく","さくそふぉーん"], + "🎺": ["とらんぺっと","あくてぃびてぃ","えんたーていめんと","がっき","おんがく"], + "🪈": ["ふるーと","たけ","よこぶえそうしゃ","ふるーとそうしゃ","おんがく","ぱいぷ","りこーだー","ふく","もっかんがっき"], + "🎸": ["ぎたー","あくてぃびてぃ","えんたーていめんと","がっき","おんがく"], + "🪕": ["ばんじょー","あくてぃびてぃ","えんたーていめんと","がっき","おんがく"], + "🎻": ["ばいおりん","あくてぃびてぃ","えんたーていめんと","がっき","おんがく"], + "🎲": ["さいころ","さい","えんたーていめんと","げーむ"], + "🧩": ["ぱずるのぴーす","てがかり","かみあう","ぴーす","ぱずる","じぐそー"], + "♟️": ["ちぇすのぽーん","ちぇす","こま","げーむ","すてこま"], + "🎯": ["てきちゅう","あくてぃびてぃ","ぶる","ぶるずあい","だーつ","えんたーていめんと","め","しあい","ひっと","ひょうてき"], + "🎳": ["ぼうりんぐ","ぼーる","しあい"], + "🪀": ["よーよー","おもちゃ","じょうげ"], + "🪁": ["たこ","おもちゃ","とぶ","まう"], + "🛝": ["すべりだい","ゆうえんち","あそび"], + "🎮": ["てれびげーむ","こんとろーらー","えんたーていめんと","げーむ","びでおげーむ"], + "👾": ["えいりあん","うちゅうじん","かいじゅう","いせいじん","かお","おとぎばなし","ふぁんたじー","もんすたー","うちゅう","UFO"], + "🎰": ["すろっとましん","あくてぃびてぃ","げーむ","すろっと"], + "🚗": ["じどうしゃ","くるま","のりもの"], + "🚙": ["きゃんぴんぐかー","れくりえーしょん","RV","のりもの"], + "🚕": ["たくしー","のりもの"], + "🛺": ["おーとりきしゃ","じんりきしゃ","とぅくとぅく"], + "🚌": ["ばす","のりもの"], + "🚎": ["とろりーばす","ばす","ろめんでんしゃ","しがいでんしゃ","のりもの"], + "🏎": ["れーしんぐかー","くるま","きょうそう"], + "🚓": ["ぱとかー","くるま","ぱとろーる","けいさつ","のりもの"], + "🚑": ["きゅうきゅうしゃ","のりもの"], + "🚒": ["しょうぼうしゃ","えんじん","えん","とらっく","のりもの"], + "🚐": ["まいくろばす","ばす","のりもの"], + "🛻": ["ぴっくあっぷとらっく","ぴっくあっぷ","とらっく","のりもの"], + "🚚": ["はいたつようとらっく","はいたつ","とらっく","のりもの"], + "🚛": ["とれーらー","おおがたとらっく","せみ","とらっく","のりもの"], + "🚜": ["とらくたー","のりもの"], + "🏍": ["れーすばいく","おーとばい","れーす"], + "🛵": ["すくーたー","もーたー"], + "🚲": ["じてんしゃ","ばいく","のりもの"], + "🦼": ["でんどうくるまいす","あくせしびりてぃ","くるまいす"], + "🦽": ["しゅどうくるまいす","あくせしびりてぃ","くるまいす"], + "🛴": ["きっくぼーど","きっく","すくーたー"], + "🛹": ["すけぼー","すけーと","ぼーど"], + "🛼": ["ろーらーすけーと","ろーらー","すけーと"], + "🛞": ["しゃりん","えん","たいや","かいてん"], + "🚨": ["ぱとらいと","くるま","ひかり","けいさつ","かいてん","のりもの","さいれん","けいこく"], + "🚔": ["ぱとかー","くるま","たいこうしゃ","けいさつ","のりもの"], + "🚍": ["ばす","たいこうしゃ","のりもの"], + "🚘": ["たいこうしゃ","じどうしゃ","くるま","のりもの"], + "🚖": ["たくしー","たいこうしゃ","のりもの"], + "🚡": ["ろーぷうぇい","くうちゅう","けーぶる","くるま","ごんどら","とらむうぇい","のりもの"], + "🚠": ["ろーぷうぇい","けーぶる","ごんどら","やま","のりもの"], + "🚟": ["こうかてつどう","てつどう","のりもの"], + "🚃": ["てつどうしゃりょう","くるま","でんき","てつどう","れっしゃ","ろめん","とろりーばす","のりもの"], + "🚋": ["ろめんでんしゃ","くるま","ろめん","とろりーばす","のりもの"], + "🚝": ["ものれーる","のりもの"], + "🚄": ["しんかんせん","てつどう","こうそく","れっしゃ","のりもの"], + "🚅": ["しんかんせん","だんがん","てつどう","こうそく","れっしゃ","のりもの"], + "🚈": ["らいとれーる","てつどう","のりもの"], + "🚞": ["さんがくてつどう","くるま","やま","てつどう","のりもの"], + "🚂": ["じょうききかんしゃ","えんじん","きかんしゃ","てつどう","じょうき","れっしゃ","のりもの"], + "🚆": ["でんしゃ","せんろ","のりもの"], + "🚇": ["ちかてつ","めとろ","のりもの"], + "🚊": ["ろめんでんしゃ","とろりーばす","のりもの"], + "🚉": ["えき","せんろ","でんしゃ","のりもの"], + "🚁": ["へりこぷたー","のりもの"], + "🛩": ["こがたこうくうき","ひこうき","のりもの"], + "✈️": ["ひこうき","のりもの"], + "🛫": ["ひこうきのりりく","ひこうき","ちぇっくいん","しゅっぱつ","のりもの"], + "🛬": ["ひこうきのちゃくりく","ひこうき","とうちゃく","ちゃくりく","のりもの"], + "🪂": ["ぱらしゅーと","ぱらせーる","すかいだいぶ","はんぐぐらいだー"], + "💺": ["ざせき","いす"], + "🛰": ["さてらいと","えいせい","うちゅう","のりもの"], + "🚀": ["ろけっと","うちゅう","のりもの"], + "🛸": ["そらとぶえんばん","UFO","うちゅうじん","いほしじん","うちゅう","くうそう"], + "🛶": ["かぬー","ぼーと"], + "⛵": ["よっと","ぼーと","りぞーと","うみ","のりもの"], + "🛥": ["もーたーぼーと","ぼーと","のりもの"], + "🚤": ["すぴーどぼーと","ぼーと","のりもの"], + "⛴": ["ふぇりー","ぼーと"], + "🛳": ["りょかくせん","りょかく","ふね","のりもの"], + "🚢": ["ふね","のりもの"], + "🛟": ["きゅうめいうきわ","うきわ","らいふじゃけっと","らいふせーばー","きゅうじょ","あんぜん"], + "⚓": ["いかり","ふね","つーる"], + "⛽": ["がそりんすたんど","ねんりょう","がそりん","きゅうゆき","さーびすすてーしょん"], + "🚧": ["こうじちゅう","こうじようふぇんす","けんせつこうじ"], + "🚏": ["ばすてい","ばす","ていし"], + "🚦": ["たてむきのしんごうき","しんごうき","しんごう","こうつう"], + "🚥": ["よこむきのしんごうき","しんごうき","しんごう","こうつう"], + "🛑": ["いちじていしひょうしき","はっかっけい","ひょうしき","ていし"], + "🎡": ["かんらんしゃ","あくてぃびてぃ","ゆうえんち","えんたーていめんと","ふぇりす"], + "🎢": ["じぇっとこーすたー","あくてぃびてぃ","ゆうえんち","こーすたー","えんたーていめんと","ろーらー"], + "🎠": ["めりーごーらんど","あくてぃびてぃ","めりーごーらうんど","えんたーていめんと","うま"], + "🏗": ["けんせつちゅう","たてもの","けんせつ"], + "🌁": ["きり","てんき"], + "🗼": ["とうきょうたわー","とうきょう","たわー"], + "🏭": ["こうじょう","たてもの"], + "⛲": ["ふんすい"], + "🎑": ["おつきみ","あくてぃびてぃ","おいわい","じゅしょうしき","えんたーていめんと","つき"], + "⛰": ["やま"], + "🏔": ["ゆきやま","さむい","やま","ゆき"], + "🗻": ["ふじさん","やま"], + "🌋": ["かざん","ふんか","やま","きしょう"], + "🗾": ["にっぽんれっとう","にっぽん","ちず"], + "🏕": ["きゃんぷ"], + "⛺": ["てんと","きゃんぷ"], + "🏞": ["こくりつこうえん","こうえん"], + "🛣": ["こうそくどうろ","はいうぇい","どうろ"], + "🛤": ["せんろ","てつどう","でんしゃ"], + "🌅": ["ひので","あさ","たいよう","てんこう"], + "🌄": ["やまからのひので","あさ","やま","たいよう","ひので","てんこう"], + "🏜": ["さばく"], + "🏖": ["びーちとかさ","びーち","かさ","ぱらそる"], + "🏝": ["むじんとう","さばく","しま"], + "🌇": ["びるにしずむゆうひ","たてもの","ゆうぐれ","たいよう","ゆうひ","てんき"], + "🌆": ["ゆうぐれのまちなみ","たてもの","まち","ゆうぐれ","ひぐれ","ふうけい","たいよう","ゆうひ","てんき"], + "🏙": ["まちなみ","たてもの","まち"], + "🌃": ["ほしぞら","よる","ほし","てんき"], + "🌉": ["よるのはし","はし","よる","てんき"], + "🌌": ["あまのがわ","うちゅう","てんき"], + "🌠": ["ながれぼし","あくてぃびてぃ","らっか","ながれる","うちゅう","ほし"], + "🎇": ["せんこうはなび","あくてぃびてぃ","おいわい","えんたーていめんと","はなび","きらきら"], + "🎆": ["はなび","あくてぃびてぃ","おいわい","えんたーていめんと"], + "🛖": ["こや","いえ","せんけいこ","ぱお"], + "🏘": ["いえ","たてもの"], + "🏰": ["せいようのしろ","たてもの","しろ","よーろっぱ"], + "🏯": ["にっぽんのしろ","たてもの","しろ","にっぽん"], + "🏟": ["すたじあむ"], + "🗽": ["じゆうのめがみ","じゆう","ぞう"], + "🏠": ["いえ","たてもの","じたく"], + "🏡": ["にわつきのいえ","たてもの","にわ","じたく","いえ"], + "🏚": ["はいきょ","たてもの","はいおく","いえ"], + "🏢": ["おふぃすびる","たてもの"], + "🏬": ["でぱーと","たてもの","てん"], + "🏣": ["にっぽんのゆうびんきょく","たてもの","にっぽん","ぽすと"], + "🏤": ["よーろっぱのゆうびんきょく","たてもの","よーろっぱ","ぽすと"], + "🏥": ["びょういん","たてもの","いし","くすり"], + "🏦": ["ぎんこう","たてもの"], + "🏨": ["ほてる","たてもの"], + "🏪": ["こんびにえんすすとあ","たてもの","こんびにえんす","すとあ"], + "🏫": ["がっこう","たてもの"], + "🏩": ["らぶほてる","たてもの","ほてる","らぶ"], + "💒": ["けっこんしき","あくてぃびてぃ","ちゃぺる","ろまんす"], + "🏛": ["れきしてきなたてもの","たてもの","れきしてきな"], + "⛪": ["きょうかい","たてもの","くりすちゃん","じゅうじか","しゅうきょう"], + "🕌": ["もすく","いすらむ","むすりむ","しゅうきょう"], + "🛕": ["ひんどぅーきょうじいん","ひんどぅーきょう","じいん","しゅうきょう"], + "🕍": ["しなごーぐ","ゆだやじん","ゆだやきょう","しゅうきょう","かいどう"], + "🕋": ["かあば","いすらむ","むすりむ","しゅうきょう"], + "⛩": ["じんじゃ","しゅうきょう","しんとう"], + "⌚": ["うでどけい","とけい"], + "📱": ["けいたいでんわ","けいたい","こみゅにけーしょん","もばいる","でんわ"], + "📲": ["ちゃくしんちゅう","やじるし","つうわ","けいたい","こみゅにけーしょん","もばいる","けいたいでんわ","じゅしん","でんわ"], + "💻": ["ぱそこん","のーとぱそこん","こんぴゅーたー","ぱーそなる"], + "⌨": ["きーぼーど","こんぴゅーたー"], + "🖥": ["ですくとっぷぱそこん","こんぴゅーたー","ですくとっぷ"], + "🖨": ["ぷりんたー","こんぴゅーたー"], + "🖱": ["3ぼたんまうす","3","ぼたん","こんぴゅーたー","まうす","さん"], + "🖲": ["とらっくぼーる","こんぴゅーたー"], + "🕹": ["じょいすてぃっく","えんたーていめんと","げーむ","びでおげーむ"], + "🗜": ["あっしゅく","つーる","けっかん"], + "💽": ["MD","ぱそこん","ひかりでぃすく","えんたーていめんと","みにでぃすく","こうがく"], + "💾": ["ふろっぴーでぃすく","こんぴゅーたー","でぃすく","ふろっぴー"], + "💿": ["CDでぃすく","ぶるーれい","CD","こんぴゅーたー","でぃすく","DVD","こうがく"], + "📀": ["DVD","ぶるーれい","CD","こんぴゅーたー","でぃすく","えんたーていめんと","こうがく"], + "📼": ["びでおてーぷ","えんたーていめんと","てーぷ","VHS","びでお","びでおかせっと"], + "📷": ["かめら","えんたーていめんと","びでお"], + "📸": ["ふらっしゅをたいたかめら","かめら","ふらっしゅ","びでお"], + "📹": ["びでおかめら","かめら","えんたーていめんと","びでお"], + "🎥": ["びでおかめら","あくてぃびてぃ","かめら","しねま","えんたーていめんと","えいが"], + "📽": ["えいしゃき","しねま","ごらく","ふぃるむ","えいが","ぷろじぇくたー","びでお"], + "🎞": ["ふぃるむのふれーむ","しねま","えんたーていめんと","ふぃるむ","ふれーむ","えいが"], + "📞": ["じゅわき","こみゅにけーしょん","でんわ","じゅしんき"], + "☎️": ["でんわ","けいたいでんわ"], + "📟": ["ぽけっとべる","こみゅにけーしょん","ぽけべる"], + "📠": ["FAX","こみゅにけーしょん; fAX"], + "📺": ["てれび","えんたーていめんと","TV","びでお"], + "📻": ["らじお","えんたーていめんと","びでお"], + "🎙": ["すたじおまいく","まいく","おんがく","すたじお"], + "🎚": ["ちょうせつばー","ちょうせつ","おんがく","ばー"], + "🎛": ["こんとろーるのぶ","こんとろーる","つまみ","おんがく"], + "⏱": ["すとっぷうぉっち","とけい"], + "⏲": ["たいまーとけい","とけい","たいまー"], + "⏰": ["めざましとけい","あらーむ","とけい"], + "🕰": ["おきどけい","とけい"], + "⏳": ["すなどけい","すな","たいまー"], + "⌛": ["すなどけい","すな","たいまー"], + "🧮": ["そろばん","けいさん","かうんと","しゅうけいひょう","すうがく"], + "📡": ["えいせいあんてな","あんてな","こみゅにけーしょん","ぱらぼらあんてな","えいせい"], + "🔋": ["でんち","ばってりー","でんし","だかえねるぎー"], + "🪫": ["ばってりーざんりょうしょう","ばってりー","でんし","ていえねるぎー"], + "🔌": ["こんせんと","でんき","ぷらぐ"], + "💡": ["でんきゅう","まんが","でんき","ひらめき","ひかり"], + "🔦": ["かいちゅうでんとう","でんき","ひかり","どうぐ","たいまつ"], + "🕯": ["ろうそく","ひかり"], + "🧯": ["しょうかき","しょうか","ひ","けす"], + "🗑": ["ごみばこ","ごみ","かん","びん"], + "🛢": ["どらむかん","どらむ","おいる"], + "🛒": ["しょっぴんぐかーと","かーと","しょっぴんぐ","とろりー"], + "💸": ["はねのはえたおさつ","ぎんこう","しへい","せいきゅうしょ","どる","とぶ","おかね","はね"], + "💵": ["どるさつ","ぎんこう","しへい","おさつ","つうか","どる","おかね"], + "💴": ["えんきごうのはいったこぎって","ぎんこう","しへい","おさつ","つうか","おかね","えん"], + "💶": ["ゆーろさつ","ぎんこう","しへい","おさつ","つうか","ゆーろ","おかね"], + "💷": ["ぽんどさつ","ぎんこう","しへい","おさつ","つうか","おかね","ぽんど"], + "💰": ["どるぶくろ","ばっぐ","どる","おかね"], + "🪙": ["こいん","きん","きんぞく","おかね","ぎん","たから"], + "💳": ["くれじっとかーど","ぎんこう","かーど","くれじっと","おかね"], + "🪪": ["みぶんしょうめいしょ","しかくじょうほう","ID","らいせんす","せきゅりてぃ"], + "🧾": ["りょうしゅうしょ","かいけい","ぼき","しょうこ","しょうめい"], + "💎": ["ほうせき","だいあもんど","じゅえる","ろまんす"], + "⚖": ["はかり","てんびん","こうせい","てんびんざ","ものさし","どうぐ","じゅうりょう","せいざ"], + "🦯": ["しろつえ","あくせしびりてぃ","めがふじゆう"], + "🧰": ["どうぐばこ","むね","せいびし","こうぐ"], + "🔧": ["れんち","どうぐ"], + "🪛": ["どらいばー","ねじ","こうぐ"], + "🔨": ["はんまー","どうぐ"], + "⚒": ["はんまーとつるはし","はんまー","つるはし","どうぐ"], + "🛠": ["はんまーとれんち","はんまー","どうぐ","れんち"], + "⛏": ["つるはし","さいくつ","どうぐ"], + "🪓": ["おの","たたきぎり","ておの","われる","もくざい","こうぐ"], + "🪚": ["もっこうようのこぎり","だいく","ざいもく","のこぎり","こうぐ"], + "🔩": ["なっととぼると","ぼると","なっと","どうぐ"], + "⚙": ["はぐるま","ぎあ","どうぐ"], + "⛓": ["くさり"], + "🪝": ["ふっく","わな","いかさま","ぺてん","ゆうわく","ふぃっしんぐ","つーる"], + "🪜": ["はしご","のぼる","よこぎ","だん","こうぐ"], + "🧱": ["れんが","ねんど","けんせつ","もるたる","かべ"], + "🪨": ["ろっく","いわ","けんぞうぶつ","おもい","こたい","いし"], + "🪵": ["もくざい","けんぞうぶつ","まるた","ざいもく","はた"], + "🔫": ["みずでっぽう","みず","ぴすとる","ふんしゃき","じゅう"], + "🧨": ["ばくちく","だいなまいと","かやく","はなび"], + "💣": ["ばくだん"], + "🔪": ["ほうちょう","きっちんないふ","ちょうり","ないふ"], + "🗡": ["たんけん","ないふ"], + "⚔": ["こうさしたけん","こうさ","けん"], + "🛡": ["たて"], + "🚬": ["きつえんまーく","あくてぃびてぃ","きつえん"], + "⚰": ["かん","し"], + "🪦": ["はかいし","ぼち","し","ぼ","はかば","はろうぃーん"], + "⚱": ["こつつぼ","し","そうぎ"], + "🏺": ["あんふぉら","みずがめざ","りょうり","のみもの","みずさし","どうぐ","せいざ"], + "🔮": ["すいしょうだま","たま","すいしょう","おとぎばなし","ふぁんたじー","うらない","どうぐ"], + "🪄": ["まほうのつえ","まほう","ぼう","まじょ","まほうつかい"], + "📿": ["じゅずじょうのいのりのようぐ","じゅず","いるい","ねっくれす","いのり","しゅうきょう"], + "🧿": ["なざーるのおまもり","じゅずだま","おまもり","よこしまし","なざーる","ごふ"], + "🪬": ["はむさ","おまもり","ふぁてぃま","て","めありー","みりあむ","ほご"], + "💈": ["りはつてんのかんばんばしら","りはつてん","とこや","さんぱつ","かんばんばしら"], + "🧲": ["じしゃく","あとらくしょん","ばてい"], + "⚗": ["じょうりゅうき","かがく","じっけん","どうぐ"], + "🧪": ["しけんかん","かがくしゃ","かがく","じっけん","じっけんしつ"], + "🧫": ["ぺとりさら","ばくてりあ","せいぶつがくしゃ","せいぶつがく","ぶんか","じっけんしつ"], + "🧬": ["DNA","せいぶつがくしゃ","しんか","いでんし","いでんしがく","せいめい"], + "🔭": ["ぼうえんきょう","つーる"], + "🔬": ["けんびきょう","つーる"], + "🕳": ["あな"], + "🩻": ["Xせん","ほね","いし","いりょう","こっかく"], + "💊": ["くすり","いし","ぴる","びょうき"], + "💉": ["ちゅうしゃき","いし","くすり","ちゅうしゃはり","ちゅうしゃ","びょうき","どうぐ","わくちん"], + "🩸": ["ち1てき","いし","くすり","けつえき","せいり"], + "🩹": ["がーぜつきばんそうこう","いし","くすり","ばんどえいど","ほうたい","ばんそうこう"], + "🩺": ["ちょうしんき","いし","くすり","しんぞう"], + "🌡": ["おんどけい","てんき","おんど"], + "🩼": ["まつばづえ","つえ","しょうがい","けが","いどうほじょ","ぼう"], + "🏷": ["らべる","にふだ"], + "🔖": ["ぶっくまーく","しおり","しるし"], + "🚽": ["といれ"], + "🪠": ["ぷらんじゃー","ふぉーすかっぷ","はいかんこう","きゅういん","といれ"], + "🚿": ["しゃわー","みず"], + "🛁": ["ばすたぶ","ふろ","よくそう"], + "🛀": ["ふろ","よくそう"], + "🪮": ["へあぴっく","あふろ","くし","かみ","ぴっく"], + "🪥": ["はぶらし","ばするーむ","ぶらし","きれい","はいしゃ","えいせい","は"], + "🪒": ["かみそり","するどい","ひげすり"], + "🧴": ["ろーしょんぼとる","ろーしょん","ほしめざい","しゃんぷー","ひやけとめ"], + "🧻": ["ぺーぱーろーる","ぺーぱーたおる","といれっとぺーぱー"], + "🧼": ["せっけん","ぼう","みずあび","くりーにんぐ","あわ","せっけんいれ"], + "🫧": ["ばぶる","げっぷ","きれい","せっけん","すいちゅう"], + "🧽": ["すぽんじ","きゅうしゅう","くりーにんぐ","たこうせい"], + "🧹": ["ほうき","くりーにんぐ","そうじ","まじょ"], + "🧺": ["ばすけっと","のうぎょう","らんどりー","ぴくにっく"], + "🪣": ["ばけつ","たる","ておけ","おおだる"], + "🔑": ["かぎ","じょう","ぱすわーど"], + "🗝": ["ふるいかぎ","かぎ","じょう","ふるい"], + "🪤": ["ねずみとりき","えさ","ねずみ","かじはどうぶつ","わなわ","わな"], + "🛋": ["そふぁーとらんぷ","そふぁー","ほてる","らんぷ"], + "🪑": ["いす","ざせき","すわる"], + "🛌": ["しゅくはくしせつ","ねる","ほてる","すいみん","べっど"], + "🛏": ["べっど","ほてる","すいみん"], + "🚪": ["どあ","とびら"], + "🪞": ["かがみ","はんしゃ","はんしゃたい","はんしゃきょう"], + "🪟": ["まど","わく","しんせんなくうき","がらす","かいこうぶ","とうめい","しかい"], + "🧳": ["てにもつ","ぱっきんぐ","りょこう","すーつけーす"], + "🛎": ["たくじょうべる","べる","ほてる"], + "🖼": ["がくにはいったしゃしん","あーと","がくぶち","びじゅつかん","かいが","しゃしん"], + "🧭": ["こんぱす","じしゃく","なびげーしょん","おりえんてーりんぐ"], + "🗺": ["せかいちず","ちず","せかい"], + "⛱": ["たてられたぱらそる","あめ","はれ","かさ","てんき"], + "🪭": ["おりたたみせんす","れいきゃく","えんりょがち","だんす","ふぁん","ふらったー","ねつ","あつい","うちき","ひろがる"], + "🗿": ["もやいぞう","もあいぞう","かお","ぞう"], + "🛍": ["かいものぶくろ","かばん","ほてる","かいもの"], + "🎈": ["ふうせん","あくてぃびてぃ","おいわい","えんたーていめんと"], + "🎏": ["こいのぼり","あくてぃびてぃ","こい","おいわい","えんたーていめんと","はた","ふきながし"], + "🎀": ["りぼん","おいわい"], + "🧧": ["あかいふうとう","ぎふと","こううん","ほんばお","らいしー","おかね"], + "🎁": ["ぷれぜんと","はこ","おいわい","えんたーていめんと","おくりもの","ほうそう"], + "🎊": ["くすだま","あくてぃびてぃ","おいわい","かみふぶき","えんたーていめんと"], + "🎉": ["くらっかー","あくてぃびてぃ","おいわい","えんたーていめんと","ぱーてぃー","じゃーん"], + "🪅": ["ぴにゃーた","おいわい","ぱーてぃー","ぴなーた"], + "🪩": ["みらーぼーる","だんす","でぃすこ","かがやき","ぱーてぃー"], + "🪆": ["いれこにんぎょう","にんぎょう","いれこ","ろしあ"], + "🎎": ["ひなまつり","あくてぃびてぃ","おいわい","にんぎょう","えんたーていめんと","まつり","にっぽん"], + "🎐": ["ふうりん","あくてぃびてぃ","かね","おいわい","えんたーていめんと","ふう"], + "🏮": ["いざかやのちょうちん","あかちょうちん","いざかや","にっぽん","ちょうちん","あかり","あか"], + "🪔": ["でぃやらんぷ","でぃや","らんぷ","おいる"], + "✉️": ["ふうとう","Eめーる","でんしめーる"], + "📩": ["めーるじゅしんちゅう","やじるし","こみゅにけーしょん","した","Eめーる","でんしめーる","ふうとう","てがみ","めーる","おくる","そうしん"], + "📨": ["めーるじゅしん","こみゅにけーしょん","Eめーる","でんしめーる","ふうとう","うけとる","てがみ","めーる","じゅしん"], + "📧": ["Eめーる","こみゅにけーしょん","でんしめーる","てがみ","めーる"], + "💌": ["らぶれたー","はーと","てがみ","あい","めーる","ろまんす"], + "📮": ["ぽすと","こみゅにけーしょん","めーる","ゆうびんうけ"], + "📪": ["はたがさがっていてとじているじょうたいのゆうびんうけ","とじる","こみゅにけーしょん","はた","さがった","めーる","ぽすと","ゆうびんうけ"], + "📫": ["はたがあがっていてとじているじょうたいのゆうびんうけ","とじる","こみゅにけーしょん","はた","めーる","ゆうびんうけ","ぽすと"], + "📬": ["はたがあがっていてひらいているじょうたいのゆうびんうけ","こみゅにけーしょん","はた","めーる","ぽすと","あける","ゆうびんうけ"], + "📭": ["はたがさがっていてひらいているゆうびんうけ","こみゅにけーしょん","はた","さげ","めーる","めーるぼっくす","あける","ゆうびんうけ"], + "📦": ["にもつ","はこ","こみゅにけーしょん","ぱっけーじ","こづつみ"], + "📯": ["ゆうびんらっぱ","こみゅにけーしょん","えんたーていめんと","かく","ぽすと","ゆうびん"], + "📥": ["じゅしんとれい","はこ","こみゅにけーしょん","てがみ","めーる","じゅしん","とれい"], + "📤": ["そうしんとれい","はこ","こみゅにけーしょん","てがみ","めーる","そうしん","とれい"], + "📜": ["まきもの","かみ"], + "📃": ["げんこう","かーる","どきゅめんと","ぺーじ"], + "📑": ["ぶっくまーくたぶ","ぶっくまーく","まーく","まーかー","たぶ"], + "📊": ["ぼうぐらふ","ばー","ちゃーと","ぐらふ"], + "📈": ["じょうしょうするぐらふ","じょうしょうちゃーと","ちゃーと","ぐらふ","せいちょう","とれんど","うわむき"], + "📉": ["かこうするぐらふ","かこうちゃーと","ちゃーと","うえ","ぐらふ","とれんど"], + "📄": ["ぶんしょ","ぺーじ"], + "📅": ["かれんだー","ひづけ"], + "📆": ["ひめくりかれんだー","かれんだー"], + "🗓": ["りんぐかれんだー","かれんだー","ぱっど","らせんじょう"], + "📇": ["めいしふぉるだ","かーど","さくいん","ろーらでっくす"], + "🗃": ["かーどふぁいる","はこ","かーど","ふぁいる"], + "🗳": ["とうひょうようしととうひょうばこ","とうひょうようし","はこ","ひょう","とうひょう"], + "🗄": ["ふぁいるしゅうのうこ","しゅうのう","ふぁいる"], + "📋": ["くりっぷぼーど"], + "🗒": ["りんぐのーと","のーと","ぱっど","らせんじょう"], + "📁": ["ふぉるだ","ふぁいる"], + "📂": ["ひらいたふぉるだ","ふぁいる","ふぉるだ","ひらいた"], + "🗂": ["しきりかーど","かーど","しきり","さくいん"], + "🗞": ["まるめたしんぶん","にゅーす","しんぶん","かみ","まるめた"], + "📰": ["しんぶん","こみゅにけーしょん","にゅーす","かみ"], + "🪧": ["ぷらかーど","でも","しがらみ","こうぎ","かんばん"], + "📓": ["のーと"], + "📕": ["とじたほん","ほん","とじている"], + "📗": ["みどりいろのほん","ほん","みどり"], + "📘": ["あおいほん","あお","ほん"], + "📙": ["おれんじいろのほん","ほん","おれんじ"], + "📔": ["そうしょくかばーののーと","ほん","かばー","そうしょく","のーと"], + "📒": ["ちょうぼ","もとちょう","のーと"], + "📚": ["しょせき","ほん"], + "📖": ["ひらいたほん","ほん","ひらいた"], + "🔗": ["りんく"], + "📎": ["くりっぷ","ぺーぱーくりっぷ"], + "🖇": ["つながったぺーぱーくりっぷ","こみゅにけーしょん","りんく","ぺーぱーくりっぷ"], + "✂️": ["はさみ","どうぐ"], + "📐": ["さんかくじょうぎ","じょうぎ","はいち","さんかく"], + "📏": ["じょうぎ","ちょくじょうぎ"], + "📌": ["がびょう","ぴん"], + "📍": ["がびょう","ぴん"], + "🧷": ["あんぜんぴん","おむつ","ぱんくろっく"], + "🪡": ["ぬいはり","ししゅう","さいほう","ぬいめ","ほうごう","したて"], + "🧵": ["すれっど","ぬいあみ","さいほう","いとまき","いと","しゅこうげい"], + "🧶": ["いと","ぼーる","かぎばりあみ","にっと","しゅこうげい"], + "🪢": ["むすびめ","ろーぷ","からんだ","ひも","よりいと","ねじれ"], + "🔐": ["こいんろっかー","しまっている","かぎ","せじょう","ぼうはん"], + "🔒": ["かぎ","とじられた","せじょう"], + "🔓": ["かいじょう","せじょう","あける"], + "🔏": ["じょうまえとぺん","いんく","じょう","ぺんさき","ぺん","ぷらいばしー"], + "🖊": ["ひだりしたむきのぼーるぺん","ぼーるぺん","こみゅにけーしょん","ぺん"], + "🖋": ["ひだりしたむきのまんねんひつ","こみゅにけーしょん","まんねんひつ","ぺん"], + "✒️": ["ぺんさき","ぺん"], + "📝": ["めも","こみゅにけーしょん","えんぴつ"], + "✏️": ["えんぴつ"], + "🖍": ["ひだりしたむきのくれよん","こみゅにけーしょん","くれよん"], + "🖌": ["ひだりしたむきのぶらし","こみゅにけーしょん","ぺいんとぶらし","え"], + "🔍": ["ひだりむきむしめがね","めがね","かくだい","けんさく","つーる"], + "🔎": ["みぎむきむしめがね","めがね","かくだい","けんさく","つーる"], + "❤️": ["あかいろのはーと","はーと"], + "🧡": ["おれんじいろのはーと","はーと","おれんじいろ"], + "💛": ["きいろのはーと","はーと","きいろ"], + "💚": ["みどりのはーと","はーと","みどり"], + "💙": ["あおのはーと","はーと","あお"], + "💜": ["むらさきのはーと","はーと","むらさき"], + "🤎": ["ちゃいろのはーと","はーと","ちゃいろ"], + "🖤": ["くろいはーと","はーと","くろ","あく","わるもの"], + "🤍": ["しろのはーと","はーと","しろ"], + "💔": ["われたはーと","はーと","こわれる","はきょく"], + "❣": ["はーとのびっくりまーく","はーと","びっくりまーく","きごう"], + "💕": ["2つのはーと","はーと","あい"], + "💞": ["かいてんするはーと","はーと","かいてん"], + "💓": ["こどうするはーと","はーと","こどう","どきどき"], + "💗": ["ひかるはーと","はーと","わくわく","ひかる","こどう","きんちょう"], + "💖": ["きらめくはーと","はーと","わくわく","きらきら"], + "💘": ["いぬかれたはーと","はーと","や","きゅーぴっど","ろまんす"], + "💝": ["りぼんつきのはーと","はーと","りぼん","ばれんたいん"], + "❤️🔥": ["もえているはーと","はーと","ひ","もえる","あい","ねつじょう","しんせいなはーと"], + "❤️🩹": ["てあてしているはーと","はーと","けんこうになる","かいぜんしている","てあてしている","かいふくしている","やみあがり","げんき"], + "💟": ["はーとのでこれーしょん","はーと"], + "☮": ["ぴーすまーく","へいわ"], + "✝": ["らてんじゅうじ","くりすちゃん","じゅうじか","しゅうきょう"], + "☪": ["ほしとみかづき","いすらむ","むすりむ","しゅうきょう"], + "🕉": ["おーむまーく","ひんどぅーきょう","おーむ","しゅうきょう"], + "☸": ["ほうりん","ぶっきょうと","だーま","しゅうきょう"], + "✡": ["だびでのほし","だびで","ゆだやじん","ゆだやきょう","しゅうきょう","ほし"], + "🔯": ["ろくぼうせい","うらない","ほし"], + "🕎": ["はぬっきーやー","しょくだい","めのーらー","しゅうきょう"], + "☯": ["いんよう","しゅうきょう","どう","どうか","ひ","かげ"], + "☦": ["はったんじゅうじか","くりすちゃん","じゅうじか","しゅうきょう"], + "🪯": ["かんだ","しゅうきょう","しーくきょうと"], + "🛐": ["れいはいしょ","しゅうきょう","れいはい"], + "⛎": ["へびつかいざ","うんぱんにん","へび","せいざ"], + "♈": ["おひつじざ","こひつじ","せいざ"], + "♉": ["おうしざ","おすうし","ゆううし","せいざ"], + "♊": ["ふたござ","ふたご","せいざ"], + "♋": ["がん","かにざ","かに","せいざ"], + "♌": ["ししざ","らいおん","せいざ"], + "♍": ["おとめざ","おとめ","しょじょ","せいざ"], + "♎": ["てんびんざ","てんびん","こうせい","はかり","せいざ"], + "♏": ["さそりざ","さそり","せいざ"], + "♐": ["いてざ","しゃしゅ","しゃしゅざ","せいざ"], + "♑": ["やぎざ","やぎ","せいざ"], + "♒": ["みずがめざ","うんぱんじん","みず","せいざ"], + "♓": ["うおざ","さかな","せいざ"], + "🆔": ["しかくかこみID","ID","しきべつ"], + "⚛": ["げんそきごう","むしんろんしゃ","げんし"], + "⚕️": ["あすくれぴおすのつえ","けんこう","せわ","いし","くすり","つえ","へび"], + "☢": ["ほうしゃのうひょうしき","ほうしゃのう"], + "☣": ["ばいおはざーどひょうしき","せいぶつさいがい"], + "📴": ["けいたいでんわでんげんおふ","けいたい","こみゅにけーしょん","もばいる","おふ","けいたいでんわ","でんわ"], + "📳": ["まなーもーど","けいたい","こみゅにけーしょん","もばいる","もーど","けいたいでんわ","でんわ","ばいぶれーしょん"], + "🈶": ["しかくかこみゆう","にほんご","あり"], + "🈚": ["しかくかこみむ","しかくかこみいな","にほんご","なし"], + "🈸": ["しかくかこみしん","しかくかこみてき","ちゅうごくご","しんせい"], + "🈺": ["しかくかこみえい","ちゅうごくご","えいぎょう"], + "🈷️": ["しかくかこみつき","にほんご","つきぎめ"], + "✴️": ["はちりょうぼし","ほし"], + "🆚": ["しかくかこみVS","たい","VS"], + "🉑": ["まるかこみきょか","まるかこみか","ちゅうごくご","かのう"], + "💮": ["しろいはな","はな","たいへんよくできました"], + "🉐": ["まるかこみとく","にほんご","とく"], + "㊙️": ["まるかこみひ","ちゅうごくご","ひょういもじ","ひ"], + "㊗️": ["まるかこみしゅく","ちゅうごくご","おめでとう","しゅく"], + "🈴": ["しかくかこみのごう","しかくかこみごう","ちゅうごくご","ごうかく","てきごう"], + "🈵": ["しかくかこみまん","ちゅうごくご","まんしつ","まんしゃ","まんたん"], + "🈹": ["しかくかこみわり","しかくかこみのわり","にほんご","わりびき"], + "🈲": ["しかくかこみきん","にほんご","きんし"], + "🅰️": ["くろしかくかこみA","A","けつえきがた"], + "🅱️": ["くろしかくかこみB","B","けつえきがた"], + "🆎": ["くろしかくかこみAB","AB","けつえきがた"], + "🆑": ["しかくかこみCL","CL"], + "🅾️": ["くろしかくかこみO","けつえきがた","O"], + "🆘": ["しかくかこみSOS","へるぷ","SOS"], + "⛔": ["たちいりきんし","たちいり","きんし","だめ","できない","きんじる","こうつう"], + "📛": ["なふだ","ばっじ","なまえ"], + "🚫": ["しんにゅうきんし","たちいり","きんし","だめ","できない","きんじる"], + "❌": ["ばつしるし","きゃんせる","きごう","かけざん","じょうざん","x"], + "⭕": ["ふといおおきなまる","まる","O"], + "💢": ["いかりまーく","いかり","まんが","げきど"], + "♨️": ["おんせん","あたたかい","わきでる","じょうき"], + "🚷": ["ほこうしゃたちいりきんし","きんし","だめ","ない","ほこうしゃ","きんじる"], + "🚯": ["ぽいすてきんし","きんし","ごみ","だめ","ない","きんしされている"], + "🚳": ["じてんしゃきんし","じてんしゃ","ばいく","きんし","だめ","できない","きんじる","のりもの"], + "🚱": ["いんようふか","ひいんりょうすい","いんりょう","きんし","だめ","ない","いんよう","きんしされている","みず"], + "🔞": ["18さいみまんきんし","18","ねんれいせいげん","じゅうはち","きんし","だめ","ない","きんしした","みせいねんしゃ"], + "📵": ["けいたいでんわきんし","けいたい","つうしん","きんし","もばいる","だめ","できない","けいたいでんわ","きんしされている","でんわ"], + "🚭": ["きんえん","きんし","だめ","できない","きんしされている","きつえん"], + "❗": ["あかいびっくりまーく","びっくり","まーく","きごう"], + "❕": ["しろいびっくりまーく","びっくり","まーく","かこみ","きごう"], + "❓": ["あかいはてなまーく","まーく","きごう","はてな"], + "❔": ["しろいはてなまーく","まーく","かこみ","きごう","はてな"], + "‼️": ["!!まーく","ばんばん","びっくり","まーく","きごう"], + "⁉️": ["!?","びっくり","いんてろばんぐ","まーく","きごう","はてな"], + "💯": ["100てん","100","ふる","ひゃく","すこあ"], + "🔅": ["ていきど","あかるさ","うすぐらい","てい"], + "🔆": ["こうきど","あかるい","あかるさ"], + "🔱": ["とらいでんと","いかり","えんぶれむ","ふね","こうぐ"], + "⚜": ["ゆりのもんしょう"], + "〽️": ["いおりてん","しるし","ぶぶん"], + "⚠️": ["けいこく"], + "🚸": ["こうさてんをわたるこどもたち","こども","こうさてん","ほこうしゃ","こうつう"], + "🔰": ["しょしんしゃまーく","しょしんしゃ","まーく","みどり","にっぽん","わかば","どうぐ","き"], + "♻️": ["りさいくるまーく","りさいくる"], + "🈯": ["しかくかこみゆび","にほんご"], + "💹": ["じょうしょうとれんどのちゃーととえんきごう","じょうしょうちゅうえんちゃーと","ぎんこう","ちゃーと","つうか","ぐらふ","せいちょう","しじょう","おかね","じょうしょう","とれんど","うわむき","えん"], + "❇️": ["きらきら"], + "✳️": ["あすたりすく (8ほんこうせい)","あすたりすく"], + "❎": ["しかくでかこまれたばつしるし","まーく","しかく"], + "✅": ["しろいふとじのちぇっくまーく","ちぇっく","まーく"], + "💠": ["どっともようのだいや","まんが","だいやもんど","きかがく","ないぶ"], + "🌀": ["さいくろん","ていきあつ","めまい","たつまき","たいふう","てんき"], + "➿": ["にじゅうのかーるじょうのるーぷ","かーる","だぶる","るーぷ"], + "🌐": ["しごせん・けいせんのあるちきゅう","ちきゅう","ちきゅうぎ","けいせん","せかい"], + "♾": ["むげん","えいえん","ふへんてき"], + "Ⓜ️": ["まるかこみM","えん","M"], + "🏧": ["ATM","ATMきごう","じどう","ぎんこう","すいとう"], + "🚾": ["といれ","けしょうしつ","おてあらい","みず","WC"], + "♿": ["くるまいす","あくせす"], + "🅿️": ["くろしかくかこみP","ちゅうしゃじょう"], + "🈳": ["しかくかこみそら","しかくかこみのそら","ちゅうごくご","そらしつ","あき","くうしゃ"], + "🈂️": ["しかくかこみさ","にっぽんじん","さーびす"], + "🛂": ["にゅうこくしんさ","ぱすぽーと"], + "🛃": ["ぜいかん"], + "🛄": ["てにもつうけとりしょ","てにもつ","うけとり"], + "🛅": ["てにもつあずかりしょ","てにもつ","ろっかー","けいこうひん"], + "🚰": ["いんりょうすい","のみもの","みず"], + "🛗": ["えれべーたー","あくせしびりてぃ","ひきあげ","しょうこうき"], + "🚹": ["だんせいのきごう","だんせいよう","といれ","おとこ","だんせい"], + "♂️": ["だんせいきごう","だんせい","おとこ"], + "🚺": ["じょせいのきごう","じょせいよう","といれ","おんな","じょせい"], + "♀️": ["じょせいきごう","じょせい","おんな"], + "⚧️": ["とらんすじぇんだーさいん","とらんすじぇんだー","ぷらいど","lgbt"], + "🚼": ["あかちゃんまーく","あかちゃん","おむつかえ"], + "🚻": ["といれ","けしょうしつ","WC"], + "🚮": ["ごみすてじょう","びんのごみすてじょう","ごみ","ごみばこ"], + "🎦": ["えいが","あくてぃびてぃ","かめら","えんたーていめんと","ふぃるむ","どうが"], + "📶": ["あんてな","ばー","けいたい","こみゅにけーしょん","もばいる","けいたいでんわ","しぐなる","でんわ"], + "🛜": ["むせん","こんぴゅーた","いんたーねっと","ねっとわーく","Wi-Fi","せつぞく"], + "🈁": ["しかくかこみここ","にっぽんじん"], + "🆖": ["しかくかこみNG","NG"], + "🆗": ["しかくかこみOK","OK"], + "🆙": ["しかくかこみUP!","まーく","うえ"], + "🆒": ["COOL","かっこいい","くーる"], + "🆕": ["しかくかこみnew","しん"], + "🆓": ["しかくかこみFREE","ふりー","むりょう"], + "0️⃣": ["0きー","0","きー","ぜろ"], + "1️⃣": ["1きー","いち","きー"], + "2️⃣": ["2きー","2","きー","に"], + "3️⃣": ["3きー","3","きー","さん"], + "4️⃣": ["4きー","4","よん","きー"], + "5️⃣": ["5きー","5","ご","きー"], + "6️⃣": ["6きー","6","きー","ろく"], + "7️⃣": ["7きー","7","きー","なな"], + "8️⃣": ["8きー","8","はち","きー"], + "9️⃣": ["9きー","9","きー","きゅう"], + "🔟": ["10きー","10","きー","じゅう"], + "🔢": ["ばんごうのにゅうりょくきごう","1234","にゅうりょく","すうじ"], + "▶️": ["みぎむきさんかく","さいせいぼたん","やじるし","さいせい","みぎ","さんかっけい"], + "⏸": ["2ほんのすいちょくばー","いちじていしぼたん","ばー","2ばい","いちじていし","すいちょく"], + "⏯": ["みぎむきのさんかっけいとにじゅうすいちょくぼう","さいせいまたはいちじていしぼたん","やじるし","いちじていし","さいせい","みぎ","さんかっけい"], + "⏹": ["ていし","ていしぼたん","しかく"], + "⏺": ["ろくが","ろくがぼたん","まる"], + "⏏️": ["とりだしまーく","とりだしぼたん"], + "⏭": ["みぎむきのにじゅうさんかっけいとすいちょくぼう","「つぎのきょく」ぼたん","やじるし","つぎのばめん","つぎのきょく","さんかっけい"], + "⏮": ["ひだりむきのにじゅうさんかっけいとすいちょくぼう","「まえのきょく」ぼたん","やじるし","まえのばめん","まえのきょく","さんかっけい"], + "⏩": ["みぎむきのにじゅうさんかっけい","はやおくりぼたん","やじるし","2ばい","こうそく","すすむ"], + "⏪": ["ひだりむきのにじゅうさんかっけい","はやもどしぼたん","やじるし","2ばい","まきもどし"], + "🔀": ["ねじりみぎむきやじるしのえもじ","しゃっふる","やじるし","こうさ"], + "🔁": ["りぴーと","りぴーとぼたん","やじるし","とけいまわり"], + "🔂": ["1きょくをりぴーとさいせい","りぴーとぼたん","やじるし","とけいまわり","いちど"], + "◀️": ["ひだりむきのさんかっけい","はんてんぼたん","やじるし","ひだり","はんてん","さんかっけい"], + "🔼": ["うわむきのさんかっけい","うえぼたん","やじるし","ぼたん","うえ"], + "🔽": ["したむきのさんかっけい","したぼたん","やじるし","ぼたん","した"], + "⏫": ["うわむきのにじゅうさんかっけい","こうそくじょうしょうぼたん","やじるし","だぶる","うえ"], + "⏬": ["したむきのにじゅうさんかっけい","こうそくだうんぼたん","やじるし","だぶる","した"], + "➡️": ["みぎむきやじるし","みぎやじるし","やじるし","しゅよう","ほうこう","ひがし"], + "⬅️": ["ひだりむきやじるし","ひだりやじるし","やじるし","しゅよう","ほうこう","にし"], + "⬆️": ["うわむきやじるし","うえやじるし","やじるし","しゅよう","ほうこう","きた"], + "⬇️": ["したむきやじるし","したやじるし","やじるし","しゅよう","ほうこう","した","みなみ"], + "↗️": ["みぎうえやじるし","やじるし","ほうこう","ななめ","ほくとう"], + "↘️": ["みぎしたやじるし","やじるし","ほうこう","ななめ","なんとう"], + "↙️": ["ひだりしたやじるし","やじるし","ほうこう","ななめ","なんせい"], + "↖️": ["ひだりうえやじるし","やじるし","ほうこう","ななめ","ほくせい"], + "↕️": ["じょうげやじるし","やじるし","ほうこう","ななめ","ほくせい"], + "↔️": ["さゆうやじるし","やじるし"], + "🔄": ["うずまきやじるし","はんとけいまわり","やじるし","ひだりまわり"], + "↪️": ["みぎむきだんつきやじるし","みぎにまがったやじるし","やじるし"], + "↩️": ["ひだりむきだんつきやじるし","ひだりにまがったやじるし","やじるし"], + "🔃": ["るーぷやじるし","とけいのはり","やじるし","とけいまわり","りろーど"], + "⤴️": ["みぎうえへかーぶするやじるし","うえへかーぶするみぎやじるし","やじるし"], + "⤵️": ["みぎしたへかーぶするやじるし","したにかーぶするみぎやじるし","やじるし","した"], + "#️⃣": ["#きー","はっしゅ","きー","ぽんど"], + "*⃣": ["あすたりすくきー","あすたりすく","きー","ほし"], + "ℹ️": ["じょうほうげん","i","いんふぉめーしょん"], + "🔤": ["あるふぁべっとにゅうりょく","abc","あるふぁべっと","にゅうりょく","らてん","もじ"], + "🔡": ["あるふぁべっとこもじにゅうりょく","abcd","にゅうりょく","らてん","もじ","こもじ"], + "🔠": ["あるふぁべっとおおもじにゅうりょく","にゅうりょく","らてん","もじ","おおもじ"], + "🔣": ["きごうにゅうりょく","にゅうりょく"], + "🎵": ["おんぷ","あくてぃびてぃ","えんたーていめんと","おんがく"], + "🎶": ["ふくすうのおんぷ","あくてぃびてぃ","えんたーていめんと","おんがく","おんぷ"], + "〰️": ["はせん","だっしゅ","きごう","なみ"], + "➰": ["かーるじょうのるーぷ","かーる","るーぷ"], + "✔️": ["ふとじのちぇっくまーく","ちぇっく","まーく"], + "➕": ["ふとじの+きごう","すうがく","ぷらす"], + "➖": ["ふとじのまいなすきごう","すうがく","まいなす"], + "➗": ["ふとじのわるきごう","わりざん","すうがく"], + "✖️": ["ふとじのかけるしるし","きゃんせる","じょうざん","かける","x"], + "🟰": ["ふといとうごう","とうしき","すうがく","ひとしい"], + "💲": ["ふとじのどるきごう","つうか","どる","おかね"], + "💱": ["がいかりょうがえ","ぎんこう","つうか","りょうがえ","おかね"], + "©️": ["こぴーらいとまーく","ちょさくけん"], + "®️": ["とうろくしょうひょうまーく","とうろくずみ","しょうひょう"], + "™️": ["しょうひょうまーく","まーく","tm","しょうひょう"], + "🔚": ["ENDとひだりやじるし","やじるし","はじ"], + "🔙": ["BACKとひだりやじるし","やじるし","もどる"], + "🔛": ["ON!とさゆうやじるし","やじるし","まーく","おん"], + "🔝": ["TOPとうえやじるし","やじるし","とっぷ","うえ"], + "🔜": ["SOONとみぎやじるし","やじるし","まもなく"], + "☑️": ["ちぇっくいりちぇっくぼっくす","とうひょう","ぼっくす","ちぇっく"], + "🔘": ["らじおぼたん","ぼたん","きかがく","らじお"], + "🔴": ["あかまる","えん","きかがく","あか"], + "🟠": ["おれんじいろのえん","えん","きかがく","おれんじ"], + "🟡": ["きいろのまる","えん","きかがく","ちゃいろ"], + "🟢": ["みどりまる","えん","きかがく","みどり"], + "🔵": ["あおまる","あお","えん","きかがく"], + "🟣": ["むらさきのまる","えん","きかがく","むらさき"], + "🟤": ["ちゃいろのまる","えん","きかがく","ちゃいろ"], + "⚫": ["くろまる","えん","きかがく"], + "⚪": ["しろまる","えん","きかがく"], + "🟥": ["あかのせいほうけい","せいほうけい","きかがく","あか"], + "🟧": ["おれんじしょくのせいほうけい","せいほうけい","きかがく","おれんじ"], + "🟨": ["きいろのせいほうけい","せいほうけい","きかがく","きいろ"], + "🟩": ["みどりのせいほうけい","せいほうけい","きかがく","みどり"], + "🟦": ["あおのせいほうけい","せいほうけい","きかがく","あお"], + "🟪": ["むらさきのせいほうけい","せいほうけい","きかがく","むらさき"], + "🟫": ["ちゃいろのせいほうけい","せいほうけい","きかがく","ちゃいろ"], + "⬛": ["くろいおおきなしかく","きかがく","せいほうけい"], + "⬜": ["しろいおおきなしかく","きかがく","せいほうけい"], + "◼️": ["くろいちゅうくらいのしかく","きかがく","せいほうけい"], + "◻️": ["しろくてちゅうくらいのしかく","きかがく","せいほうけい"], + "◾": ["くろくてちゅうくらいのちいさいしかく","きかがく","せいほうけい"], + "◽": ["しろいちゅうくらいのちいさなしかく","きかがく","せいほうけい"], + "▪️": ["くろいちいさなしかく","きかがく","せいほうけい"], + "▫️": ["しろいちいさなしかく","きかがく","せいほうけい"], + "🔸": ["ちいさいおれんじのだいやもんど","だいやもんど","きかがく","おれんじ"], + "🔹": ["ちいさくてあおいだいやもんど","あお","だいやもんど","きかがく"], + "🔶": ["おおきいおれんじのだいや","だいやもんど","きかがく","おれんじ"], + "🔷": ["おおきくてあおいだいやもんど","あお","だいやもんど","きかがく"], + "🔺": ["うわむきのあかいさんかっけい","うえ","きかがく","あか"], + "🔻": ["したむきのさんかっけい","だうん","きかがく","あか"], + "🔲": ["くろいしかくぼたん","ぼたん","きかがく","せいほうけい"], + "🔳": ["しろいしかくぼたん","ぼたん","きかがく","かこみ","しかく"], + "🔈": ["すぴーかー","おんりょう"], + "🔉": ["おんりょうしょう","でんげんがはいったすぴーかー","ひくい","すぴーかー","おんりょう","なみ"], + "🔊": ["おんりょうだい","だいおんりょうのすぴーかー","3","えんたーていめんと","たかい","おとのおおきい","すぴーかー","ぼりゅーむ"], + "🔇": ["むおんのすぴーかー","すぴーかー","おふ","みゅーと","せいおん","むおん","おんりょう"], + "📣": ["めがほん","おうえん","こみゅにけーしょん","かくせいき"], + "📢": ["かくせいき","こみゅにけーしょん","おおごえ","すぴーかー","ぱぶりっくあどれす","めがほん"], + "🔔": ["べる"], + "🔕": ["みゅーと","すらっしゅべる","かね","きんじられた","だめ","ない","きんし","しずか"], + "🃏": ["とらんぷのじょーかー","かーど","えんたーていめんと","げーむ","じょーかー","ぷれい"], + "🀄": ["まーじゃんぱいのちゅう","げーむ","まーじゃん","あか"], + "♠️": ["とらんぷのすぺーど","かーど","げーむ","すぺーど","すーつ"], + "♣️": ["とらんぷのくらぶ","かーど","くらぶ","げーむ","すーつ"], + "♥️": ["とらんぷのはーと","かーど","げーむ","はーと","すーつ"], + "♦️": ["とらんぷのだいや","かーど","だいや","だいやもんど","げーむ","すーつ"], + "🎴": ["はなふだ","あくてぃびてぃ","かーど","えんたーていめんと","はな","げーむ","にっぽん","ぷれい"], + "👁🗨": ["ふきだしのめ","ふきだし","め","すぴーち","しょうにん"], + "🗨": ["ひだりむきのふきだし","せりふ","すぴーち"], + "💭": ["かんがえふきだし","ふきだし","あわ","まんが","かんがえ"], + "🗯": ["みぎむきのいかりのふきだし","いかり","ふきだし","あわ","げきど"], + "💬": ["ふきだし","あわ","まんが","せりふ","すぴーち"], + "🕐": ["1じ","0ふん","1","とけい","とき","いち"], + "🕑": ["2じ","0ふん","2","とけい","とき","に"], + "🕒": ["3じ","0ふん","3","とけい","とき","さん"], + "🕓": ["4じ","0ふん","4","とけい","よん","とき"], + "🕔": ["5じ","0ふん","5","とけい","ご","とき"], + "🕕": ["6じ","0ふん","6","とけい","とき","ろく"], + "🕖": ["7じ","0ふん","7","とけい","とき","なな"], + "🕗": ["8じ","0ふん","8","とけい","はち","とき"], + "🕘": ["9じ","0ふん","9","とけい","きゅう","とき"], + "🕙": ["10じ","0ふん","10","とけい","とき","じゅう"], + "🕚": ["11じ","0ふん","11","とけい","じゅういち","とき"], + "🕛": ["12じ","0ふん","12","とけい","じゅうに","とき"], + "🕜": ["1じはん","1じ","はん","じこく","いち","30"], + "🕝": ["2じはん","2じ","はん","じこく","30","に"], + "🕞": ["3じはん","3じ","はん","じこく","30","さん"], + "🕟": ["4じはん","30","4じ","じこく","よん","はん"], + "🕠": ["5じはん","30","5じ","じこく","ご","はん"], + "🕡": ["6じはん","30","6じ","じこく","ろく","はん"], + "🕢": ["7じはん","30","7じ","じこく","なな","はん"], + "🕣": ["8じはん","30","8じ","じこく","はち","はん"], + "🕤": ["9じはん","30","9じ","じこく","きゅう","はん"], + "🕥": ["10じはん","10じ","はん","じこく","じゅう","30"], + "🕦": ["11じはん","11じ","はん","じこく","じゅういち","30"], + "🕧": ["12じはん","12じ","はん","じこく","30","じゅうに"], + "🏳": ["なびくしろはた","はた","なびく"], + "🏴": ["なびくくろはた","はた","なびく"], + "🏁": ["ちぇっかーふらっぐ","いちまつもよう","はた","れーす"], + "🚩": ["さんかくはた","はた","ぽすと"], + "🎌": ["こうさき","あくてぃびてぃ","おいわい","こうさ","こうさした","はた","にっぽん"], + "🏴☠️": ["かいぞくはた","はた","かいぞく"], + "🏳️🌈": ["れいんぼーふらっぐ","ふらっぐ","れいんぼー","ぷらいど","lgbt"], + "🏳️⚧️": ["とらすじぇんだーふらっぐ","ふらっぐ","とらんすじぇんだー","ぷらいど","lgbt"], + "🇦🇨": ["あせんしょんとうのはた","あせんしょん","こっき","しま"], + "🇦🇩": ["あんどらこっき","あんどら","こっき"], + "🇦🇪": ["あらぶしゅちょうこくれんぽうこっき","しゅちょうこく","こっき","あらぶしゅちょうこくれんぽう","れんぽう"], + "🇦🇫": ["あふがにすたんこっき","あふがにすたん","こっき"], + "🇦🇬": ["あんてぃぐあばーぶーだこっき","あんてぃぐあ","ばーぶーだ","こっき"], + "🇦🇮": ["あんぎらとうのはた","あんぎらとう","こっき"], + "🇦🇱": ["あるばにあこっき","あるばにあ","こっき"], + "🇦🇲": ["あるめにあこっき","あるめにあ","こっき"], + "🇦🇴": ["あんごらこっき","あんごら","こっき"], + "🇦🇶": ["なんきょくたいりくのはた","なんきょくたいりく","こっき"], + "🇦🇷": ["あるぜんちんこっき","あるぜんちん","こっき"], + "🇦🇸": ["あめりかりょうさもあのはた","あめりかりょう","こっき","さもあ"], + "🇦🇹": ["おーすとりあこっき","おーすとりあ","こっき"], + "🇦🇺": ["おーすとらりあこっき","おーすとらりあ","こっき","はーど","まくどなるど"], + "🇦🇼": ["あるばこっき","あるば","こっき"], + "🇦🇽": ["おーらんどしょとうのはた","おーらんどしょとう","こっき"], + "🇦🇿": ["あぜるばいじゃんこっき","あぜるばいじゃん","こっき"], + "🇧🇦": ["ぼすにあへるつぇごびなこっき","ぼすにあ","こっき","へるつぇごびな"], + "🇧🇧": ["ばるばどすこっき","ばるばどす","こっき"], + "🇧🇩": ["ばんぐらでしゅこっき","ばんぐらでしゅ","こっき"], + "🇧🇪": ["べるぎーこっき","べるぎー","こっき"], + "🇧🇫": ["ぶるきなふぁそこっき","ぶるきなふぁそ","こっき"], + "🇧🇬": ["ぶるがりあこっき","ぶるがりあ","こっき"], + "🇧🇭": ["ばーれーんこっき","ばーれーん","こっき"], + "🇧🇮": ["ぶるんじこっき","ぶるんじ","こっき"], + "🇧🇯": ["べなんこっき","べなん","こっき"], + "🇧🇱": ["さん・ばるてるみーとうのはた","ばるてるみー","こっき","さん"], + "🇧🇲": ["ばみゅーだしょとうのはた","ばみゅーだしょとう","こっき"], + "🇧🇳": ["ぶるねいこっき","ぶるねい","だるさらーむ","こっき"], + "🇧🇴": ["ぼりびあこっき","ぼりびあ","こっき"], + "🇧🇶": ["かりぶかいのおらんだりょうとうのはた","ぼねーるとう","かりぶかい","ゆーすたてぃうす","こっき","おらんだ","さば","しんと"], + "🇧🇷": ["ぶらじるこっき","ぶらじる","こっき"], + "🇧🇸": ["ばはまこっき","ばはま","こっき"], + "🇧🇹": ["ぶーたんこっき","ぶーたん","こっき"], + "🇧🇼": ["ぼつわなこっき","ぼつわな","こっき"], + "🇧🇾": ["べらるーしこっき","べらるーし","こっき"], + "🇧🇿": ["べりーずこっき","べりーず","こっき"], + "🇨🇦": ["かなだこっき","かなだ","こっき"], + "🇨🇨": ["ここすしょとうのはた","ここす","こっき","しょとう","きーりんぐ"], + "🇨🇩": ["こんごこっき - きんしゃさ","こんご","こんご - きんしゃさ","こんごみんしゅきょうわこく","こっき","きんしゃさ","きょうわこく"], + "🇨🇫": ["ちゅうおうあふりかこっき","ちゅうおうあふりかきょうわこく","こっき","きょうわこく"], + "🇨🇬": ["こんごのはた - ぶらざびる","ぶらざびる","こんご","こんごきょうわこく","こんご - ぶらざびる","こっき","きょうわこく"], + "🇨🇭": ["すいすこっき","こっき","すいす"], + "🇨🇮": ["こーとじぼわーるこっき","こーとじぼわーる","こっき"], + "🇨🇰": ["くっくしょとうこっき","くっく","こっき","しょとう"], + "🇨🇱": ["ちりこっき","ちり","こっき"], + "🇨🇲": ["かめるーんこっき","かめるーん","こっき"], + "🇨🇳": ["ちゅうごくこっき","ちゅうごく","こっき"], + "🇨🇴": ["ころんびあこっき","ころんびあ","こっき"], + "🇨🇷": ["こすたりかこっき","こすたりか","こっき"], + "🇨🇺": ["きゅーばこっき","きゅーば","こっき"], + "🇨🇻": ["かーぼべるでこっき","かーぼ","けーぷ","こっき","べるで"], + "🇨🇼": ["きゅらそーとうのはた","あんてぃるしょとう","きゅらそー","こっき"], + "🇨🇽": ["くりすますとうのはた","くりすます","こっき","しま"], + "🇨🇾": ["きぷろすこっき","きぷろす","こっき"], + "🇨🇿": ["ちぇここっき","ちぇこきょうわこく","こっき"], + "🇩🇪": ["どいつこっき","こっき","どいつ"], + "🇩🇯": ["じぶちこっき","じぶち","こっき"], + "🇩🇰": ["でんまーくこっき","でんまーく","こっき"], + "🇩🇲": ["どみにかこっき","どみにか","こっき"], + "🇩🇴": ["どみにかきょうわこくこっき","どみにかきょうわこく","こっき"], + "🇩🇿": ["あるじぇりあこっき","あるじぇりあ","こっき"], + "🇪🇨": ["えくあどるこっき","えくあどる","こっき"], + "🏴": ["いんぐらんどのはた","いんぐらんど","こっき"], + "🇪🇪": ["えすとにあこっき","えすとにあ","こっき"], + "🇪🇬": ["えじぷとこっき","えじぷと","こっき"], + "🇪🇭": ["にしさはらのはた","こっき","さはら","にし","にしさはら"], + "🇪🇷": ["えりとりあこっき","えりとりあ","こっき"], + "🇪🇸": ["すぺいんこっき","こっき","すぺいん","せうた","めりりゃ"], + "🇪🇹": ["えちおぴあこっき","えちおぴあ","こっき"], + "🇪🇺": ["おうしゅうはた","おうしゅうれんごう","こっき"], + "🇫🇮": ["ふぃんらんどこっき","ふぃんらんど","こっき"], + "🇫🇯": ["ふぃじーこっき","ふぃじー","こっき"], + "🇫🇰": ["ふぉーくらんどしょとうのはた","ふぉーくらんど","ふぉーくらんどしょとう","こっき","しょとう","まるびなす"], + "🇫🇲": ["みくろねしあこっき","こっき","みくろねしあ"], + "🇫🇴": ["ふぇろーしょとうのはた","ふぇろー","はた","しょとう"], + "🇫🇷": ["ふらんすこっき","こっき","ふらんす","くりっぱーとんとう","せんと・まーちん","さん・まるたん"], + "🇬🇦": ["がぼんこっき","こっき","がぼん"], + "🇬🇧": ["いぎりすこっき","いぎりす","いぎりすりょう","こーんうぉーる","いんぐらんど","こっき","ぐれーとぶりてん","あいるらんど","きたあいるらんど","すこっとらんど","UK","ゆにおんじゃっく","れんごう","れんごうおうこく","うぇーるず"], + "🇬🇩": ["ぐれなだこっき","こっき","ぐれなだ"], + "🇬🇪": ["じょーじあこっき","こっき","じょーじあ"], + "🇬🇫": ["ふらんすりょうぎあなのはた","こっき","ふらんすりょう","ぎあな"], + "🇬🇬": ["がーんじーこっき","こっき","がーんじー"], + "🇬🇭": ["がーなこっき","こっき","がーな"], + "🇬🇮": ["じぶらるたるこっき","こっき","じぶらるたる"], + "🇬🇱": ["ぐりーんらんどこっき","こっき","ぐりーんらんど"], + "🇬🇲": ["がんびあこっき","こっき","がんびあ"], + "🇬🇳": ["ぎにあこっき","こっき","ぎにあ"], + "🇬🇵": ["ぐあどるーぷこっき","こっき","ぐあどるーぷ"], + "🇬🇶": ["せきどうぎにあこっき","せきどうぎにあ","こっき","ぎにあ"], + "🇬🇷": ["ぎりしゃこっき","こっき","ぎりしゃ"], + "🇬🇸": ["さうすじょーじあ・さうすさんどうぃっちしょとうこっき","こっき","じょーじあ","しょとう","さうす","さうすじょーじあ","さうすさんどうぃっち"], + "🇬🇹": ["ぐあてまらこっき","こっき","ぐあてまら"], + "🇬🇺": ["ぐあむはた","こっき","ぐあむ"], + "🇬🇼": ["ぎにあびさうこっき","びさう","こっき","ぎにあ"], + "🇬🇾": ["がいあなこっき","こっき","がいあな"], + "🇭🇰": ["ほんこんのはた","ちゅうごく","こっき","ほんこん"], + "🇭🇳": ["ほんじゅらすこっき","こっき","ほんじゅらす"], + "🇭🇷": ["くろあちあこっき","くろあちあ","こっき"], + "🇭🇹": ["はいちこっき","こっき","はいち"], + "🇭🇺": ["はんがりーこっき","こっき","はんがりー"], + "🇮🇨": ["かなりあしょとうのはた","かなりあ","こっき","しょとう"], + "🇮🇩": ["いんどねしあこっき","こっき","いんどねしあ"], + "🇮🇪": ["あいるらんどこっき","こっき","あいるらんど"], + "🇮🇱": ["いすらえるこっき","こっき","いすらえる"], + "🇮🇲": ["まんとうのはた","こっき","まんとう"], + "🇮🇳": ["いんどこっき","こっき","いんど"], + "🇮🇴": ["いぎりすりょういんどようちいきのはた","いぎりすりょう","ちゃごす","はた","いんどよう","しま","でぃえごがるしあ"], + "🇮🇶": ["いらくこっき","こっき","いらく"], + "🇮🇷": ["いらんこっき","こっき","いらん"], + "🇮🇸": ["あいすらんどこっき","こっき","あいすらんど"], + "🇮🇹": ["いたりあこっき","こっき","いたりあ"], + "🇯🇪": ["じゃーじーだいかんかんかつくのはた","こっき","じゃーじーだいかんかんかつく"], + "🇯🇲": ["じゃまいかこっき","こっき","じゃまいか"], + "🇯🇴": ["よるだんこっき","こっき","よるだん"], + "🇯🇵": ["にっぽんこっき","こっき","にっぽん"], + "🇰🇪": ["けにあこっき","こっき","けにあ"], + "🇰🇬": ["きるぎすこっき","こっき","きるぎす"], + "🇰🇭": ["かんぼじあこっき","かんぼじあ","こっき"], + "🇰🇮": ["きりばすこっき","こっき","きりばす"], + "🇰🇲": ["こもろこっき","こもろ","こっき"], + "🇰🇳": ["せんとくりすとふぁーねいびすこっき","こっき","きっつ","ねいびす","せんと"], + "🇰🇵": ["きたちょうせんこっき","こっき","ちょうせん","きた","きたちょうせん"], + "🇰🇷": ["かんこくこっき","こっき","かんこく","みなみ","だいかんみんこく"], + "🇰🇼": ["くうぇーとこっき","こっき","くうぇーと"], + "🇰🇾": ["けいまんしょとうのはた","けいまん","こっき","しょとう"], + "🇰🇿": ["かざふすたんこっき","こっき","かざふすたん"], + "🇱🇦": ["らおすこっき","こっき","らおす"], + "🇱🇧": ["ればのんこっき","こっき","ればのん"], + "🇱🇨": ["せんとるしあこっき","こっき","せんとるしあ"], + "🇱🇮": ["りひてんしゅたいんこっき","こっき","りひてんしゅたいん"], + "🇱🇰": ["すりらんかこっき","こっき","すりらんか"], + "🇱🇷": ["りべりあこっき","こっき","りべりあ"], + "🇱🇸": ["れそとこっき","こっき","れそと"], + "🇱🇹": ["りとあにあこっき","こっき","りとあにあ"], + "🇱🇺": ["るくせんぶるくこっき","こっき","るくせんぶるく"], + "🇱🇻": ["らとびあこっき","こっき","らとびあ"], + "🇱🇾": ["りびあこっき","こっき","りびあ"], + "🇲🇦": ["もろっここっき","こっき","もろっこ"], + "🇲🇨": ["もなここっき","こっき","もなこ"], + "🇲🇩": ["もるどばこっき","こっき","もるどば"], + "🇲🇪": ["もんてねぐろこっき","こっき","もんてねぐろ"], + "🇲🇬": ["まだがすかるこっき","こっき","まだがすかる"], + "🇲🇭": ["まーしゃるしょとうこっき","こっき","しょとう","まーしゃる"], + "🇲🇰": ["まけどにあこっき","こっき","まけどにあ"], + "🇲🇱": ["まりこっき","こっき","まり"], + "🇲🇲": ["みゃんまーこっき","びるま","こっき","みゃんまー"], + "🇲🇳": ["もんごるこっき","こっき","もんごる"], + "🇲🇴": ["まかおのはた","ちゅうごく","こっき","まかお"], + "🇲🇵": ["きたまりあなしょとうのはた","こっき","しょとう","まりあな","きた","きたまりあな"], + "🇲🇶": ["まるてぃにーくのはた","はた","まるてぃにーく"], + "🇲🇷": ["もーりたにあこっき","こっき","もーりたにあ"], + "🇲🇸": ["もんとせらとのはた","はた","もんとせらと"], + "🇲🇹": ["まるたこっき","こっき","まるた"], + "🇲🇺": ["もーりしゃすこっき","こっき","もーりしゃす"], + "🇲🇻": ["もるでぃぶこっき","こっき","もるでぃぶ"], + "🇲🇼": ["まらういこっき","こっき","まらうい"], + "🇲🇽": ["めきしここっき","こっき","めきしこ"], + "🇲🇾": ["まれーしあこっき","こっき","まれーしあ"], + "🇲🇿": ["もざんびーくこっき","こっき","もざんびーく"], + "🇳🇦": ["なみびあこっき","こっき","なみびあ"], + "🇳🇨": ["にゅーかれどにあのはた","こっき","にゅー","にゅーかれどにあ"], + "🇳🇪": ["にじぇーるこっき","こっき","にじぇーる"], + "🇳🇫": ["のーふぉーくとうのはた","はた","しま","のーふぉーく"], + "🇳🇬": ["ないじぇりあこっき","こっき","ないじぇりあ"], + "🇳🇮": ["にからぐあこっき","こっき","にからぐあ"], + "🇳🇱": ["おらんだこっき","こっき","おらんだ"], + "🇳🇴": ["のるうぇーこっき","はた","のるうぇー","ぶーべ","すヴぁーるばる","やんまいえん"], + "🇳🇵": ["ねぱーるこっき","こっき","ねぱーる"], + "🇳🇷": ["なうるこっき","こっき","なうる"], + "🇳🇺": ["にうえこっき","こっき","にうえ"], + "🇳🇿": ["にゅーじーらんどこっき","こっき","にゅー","にゅーじーらんど"], + "🇴🇲": ["おまーんこっき","こっき","おまーん"], + "🇵🇦": ["ぱなまこっき","こっき","ぱなま"], + "🇵🇪": ["ぺるーこっき","こっき","ぺるー"], + "🇵🇫": ["ふらんすりょうぽりねしあのはた","こっき","ふらんすりょう","ぽりねしあ"], + "🇵🇬": ["ぱぷあにゅーぎにあこっき","こっき","ぎにあ","にゅー","ぱぷあにゅーぎにあ"], + "🇵🇭": ["ふぃりぴんこっき","こっき","ふぃりぴん"], + "🇵🇰": ["ぱきすたんこっき","こっき","ぱきすたん"], + "🇵🇱": ["ぽーらんどこっき","こっき","ぽーらんど"], + "🇵🇲": ["さんぴえーるとう・みくろんとうのはた","はた","みくろん","ぴえーる","さん"], + "🇵🇳": ["ぴとけあんしょとうのはた","はた","しょとう","ぴとけあん"], + "🇵🇷": ["ぷえるとりこのはた","こっき","ぷえるとりこ"], + "🇵🇸": ["ぱれすちなじちせいふのはた","こっき","ぱれすちな"], + "🇵🇹": ["ぽるとがるこっき","こっき","ぽるとがる"], + "🇵🇼": ["ぱらおこっき","こっき","ぱらお"], + "🇵🇾": ["ぱらぐあいこっき","こっき","ぱらぐあい"], + "🇶🇦": ["かたーるこっき","こっき","かたーる"], + "🇷🇪": ["れゆにおんのはた","はた","れゆにおん"], + "🇷🇴": ["るーまにあこっき","こっき","るーまにあ"], + "🇷🇸": ["せるびあこっき","こっき","せるびあ"], + "🇷🇺": ["ろしあこっき","こっき","ろしあ"], + "🇷🇼": ["るわんだこっき","こっき","るわんだ"], + "🇸🇦": ["さうじあらびあこっき","こっき","さうじあらびあ"], + "🏴": ["すこっとらんどのはた","すこっとらんど","はた"], + "🇸🇧": ["そろもんしょとうこっき","はた","しょとう","そろもん"], + "🇸🇨": ["せーしぇるこっき","こっき","せーしぇる"], + "🇸🇩": ["すーだんこっき","こっき","すーだん"], + "🇸🇪": ["すうぇーでんこっき","こっき","すうぇーでん"], + "🇸🇬": ["しんがぽーるこっき","こっき","しんがぽーる"], + "🇸🇭": ["せんとへれなとうのはた","はた","へれな","せんと"], + "🇸🇮": ["すろべにあこっき","こっき","すろべにあ"], + "🇸🇰": ["すろばきあこっき","こっき","すろばきあ"], + "🇸🇱": ["しえられおねこっき","こっき","しえられおね"], + "🇸🇲": ["さんまりのこっき","こっき","さんまりの"], + "🇸🇳": ["せねがるこっき","こっき","せねがる"], + "🇸🇴": ["そまりあこっき","こっき","そまりあ"], + "🇸🇷": ["すりなむこっき","こっき","すりなむ"], + "🇸🇸": ["みなみすーだんこっき","こっき","みなみ","みなみすーだん","すーだん"], + "🇸🇹": ["さんとめぷりんしぺこっき","こっき","ぷりんしぺ","ぷりんしぴ","さんとめ","さぉんとめー"], + "🇸🇻": ["えるさるばどるこっき","えるさるばどる","こっき"], + "🇸🇽": ["せんと・まーちんとうのはた","はた","まーちん","せんと"], + "🇸🇾": ["しりあこっき","こっき","しりあ"], + "🇸🇿": ["すわじらんどこっき","こっき","すわじらんど"], + "🇹🇦": ["とりすたんだくーにゃのはた","はた","とりすたん・だ・くーにゃ"], + "🇹🇨": ["たーくす・かいこすしょとうのはた","かいこす","はた","しょとう","たーくす"], + "🇹🇩": ["ちゃどこっき","ちゃど","こっき"], + "🇹🇫": ["ふらんすりょうなんぽう・なんきょくちいきのはた","なんきょく","こっき","ふらんすりょう"], + "🇹🇬": ["とーごこっき","こっき","とーご"], + "🇹🇭": ["たいこっき","こっき","たい"], + "🇹🇯": ["たじきすたんこっき","こっき","たじきすたん"], + "🇹🇰": ["とけらうはた","こっき","とけらう"], + "🇹🇱": ["ひがしてぃもーるこっき","ひがし","ひがしてぃもーる","こっき","てぃもーる・れすて"], + "🇹🇲": ["とるくめにすたんこっき","こっき","とるくめにすたん"], + "🇹🇳": ["ちゅにじあこっき","こっき","ちゅにじあ"], + "🇹🇴": ["とんがこっき","こっき","とんが"], + "🇹🇷": ["とるここっき","こっき","とるこ"], + "🇹🇹": ["とりにだーどとばごこっき","こっき","とばご","とりにだーど"], + "🇹🇻": ["つばるこっき","こっき","つばる"], + "🇹🇼": ["たいわんのはた","ちゅうごく","こっき","たいわん"], + "🇹🇿": ["たんざにあこっき","こっき","たんざにあ"], + "🇺🇦": ["うくらいなこっき","こっき","うくらいな"], + "🇺🇬": ["うがんだこっき","こっき","うがんだ"], + "🇺🇳": ["こくれんのはた","はた","こくれん","れんごう","こくさい"], + "🇺🇸": ["あめりかこっき","あめりか","はた","ごうしゅう","がっしゅうこく","あめりかがっしゅうこく","がっしゅうこくりょうゆうしょうりとう"], + "🇺🇾": ["うるぐあいこっき","こっき","うるぐあい"], + "🇺🇿": ["うずべきすたんこっき","こっき","うずべきすたん"], + "🇻🇦": ["ばちかんしこっき","こっき","ばちかん"], + "🇻🇨": ["せんとびんせんと・ぐれなでぃーんこっき","こっき","ぐれなでぃーんしょとう","せんと","びんせんと"], + "🇻🇪": ["べねずえらこっき","こっき","べねずえら"], + "🇻🇬": ["いぎりすりょうヴぁぁーじんしょとうのはた","いぎりすりょう","こっき","しま","ヴぁーじん"], + "🇻🇮": ["あめりかりょうヴぁーじんしょとうのはた","あめりか","こっき","しま","あめりかがっしゅうこく","がっしゅうこく","ヴぁーじん"], + "🇻🇳": ["べとなむこっき","こっき","べとなむ","ヴぇとなむ"], + "🇻🇺": ["ばぬあつこっき","こっき","ばぬあつ"], + "🏴": ["うぇーるずのはた","うぇーるず","はた"], + "🇼🇫": ["うぉりす・ふつなのはた","こっき","ふつな","うぉりす"], + "🇼🇸": ["さもあこっき","こっき","さもあ"], + "🇽🇰": ["こそぼこっき","こっき","こそぼ"], + "🇾🇪": ["いえめんこっき","こっき","いえめん"], + "🇾🇹": ["まよっとのはた","こっき","まよっと"], + "🇿🇦": ["みなみあふりかこっき","こっき","みなみ","みなみあふりか"], + "🇿🇲": ["ざんびあこっき","こっき","ざんびあ"], + "🇿🇼": ["じんばぶえこっき","こっき","じんばぶえ"], + "": ["しぶや109", "SHIBUYA109", "109"] +} diff --git a/packages/frontend/src/widgets/WidgetActivity.calendar.vue b/packages/frontend/src/widgets/WidgetActivity.calendar.vue index bb5a2676dd..58d231d9d4 100644 --- a/packages/frontend/src/widgets/WidgetActivity.calendar.vue +++ b/packages/frontend/src/widgets/WidgetActivity.calendar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue index 0e87ec3ec3..41c6126c72 100644 --- a/packages/frontend/src/widgets/WidgetActivity.chart.vue +++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index d2842143b1..9b65ca5e4a 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import XCalendar from './WidgetActivity.calendar.vue'; import XChart from './WidgetActivity.chart.vue'; import { GetFormResultType } from '@/scripts/form.js'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; @@ -76,7 +76,7 @@ const toggleView = () => { save(); }; -os.apiGet('charts/user/notes', { +misskeyApiGet('charts/user/notes', { userId: $i.id, span: 'day', limit: 7 * 21, diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue index fef026244c..00001005de 100644 --- a/packages/frontend/src/widgets/WidgetAichan.vue +++ b/packages/frontend/src/widgets/WidgetAichan.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue index c17e9728a5..70fac9ae55 100644 --- a/packages/frontend/src/widgets/WidgetAiscript.vue +++ b/packages/frontend/src/widgets/WidgetAiscript.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,7 +25,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import { GetFormResultType } from '@/scripts/form.js'; import * as os from '@/os.js'; import MkContainer from '@/components/MkContainer.vue'; -import { createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; @@ -69,19 +69,7 @@ const run = async () => { storageKey: 'widget', token: $i?.token, }), { - in: (q) => { - return new Promise(ok => { - os.inputText({ - title: q, - }).then(({ canceled, result: a }) => { - if (canceled) { - ok(''); - } else { - ok(a); - } - }); - }); - }, + in: aiScriptReadline, out: (value) => { logs.value.push({ id: Math.random().toString(), diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index 10248a840a..fa79e4aeb7 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -18,7 +18,7 @@ import { Interpreter, Parser } from '@syuilo/aiscript'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import * as os from '@/os.js'; -import { createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import { $i } from '@/account.js'; import MkAsUi from '@/components/MkAsUi.vue'; import MkContainer from '@/components/MkContainer.vue'; @@ -64,19 +64,7 @@ async function run() { root.value = _root.value; }), }, { - in: (q) => { - return new Promise(ok => { - os.inputText({ - title: q, - }).then(({ canceled, result: a }) => { - if (canceled) { - ok(''); - } else { - ok(a); - } - }); - }); - }, + in: aiScriptReadline, out: (value) => { // nop }, diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index 0a83eba9c1..36ba9f8255 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -27,7 +27,7 @@ import * as Misskey from 'misskey-js'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; @@ -70,7 +70,7 @@ const fetch = () => { now.setHours(0, 0, 0, 0); if (now > lfAtD) { - os.api('users/following', { + misskeyApi('users/following', { limit: 18, birthday: now.toISOString(), userId: $i.id, diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue index 11082c1e3f..6080e120ec 100644 --- a/packages/frontend/src/widgets/WidgetButton.vue +++ b/packages/frontend/src/widgets/WidgetButton.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,7 @@ import { Interpreter, Parser } from '@syuilo/aiscript'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import * as os from '@/os.js'; -import { createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import { $i } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; @@ -56,19 +56,7 @@ const run = async () => { storageKey: 'widget', token: $i?.token, }), { - in: (q) => { - return new Promise(ok => { - os.inputText({ - title: q, - }).then(({ canceled, result: a }) => { - if (canceled) { - ok(''); - } else { - ok(a); - } - }); - }); - }, + in: aiScriptReadline, out: (value) => { // nop }, diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index b3f814a0a7..06b71311c4 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="[$style.root, { _panel: !widgetProps.transparent }]" data-cy-mkw-calendar> <div :class="[$style.calendar, { [$style.isHoliday]: isHoliday }]"> <p :class="$style.monthAndYear"> - <span :class="$style.year">{{ i18n.t('yearX', { year }) }}</span> - <span :class="$style.month">{{ i18n.t('monthX', { month }) }}</span> + <span :class="$style.year">{{ i18n.tsx.yearX({ year }) }}</span> + <span :class="$style.month">{{ i18n.tsx.monthX({ month }) }}</span> </p> - <p v-if="month === 1 && day === 1" class="day">🎉{{ i18n.t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p> - <p v-else :class="$style.day">{{ i18n.t('dayX', { day }) }}</p> + <p v-if="month === 1 && day === 1" class="day">🎉{{ i18n.tsx.dayX({ day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p> + <p v-else :class="$style.day">{{ i18n.tsx.dayX({ day }) }}</p> <p :class="$style.weekDay">{{ weekDay }}</p> </div> <div :class="$style.info"> diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue index aa49269017..9d231ae715 100644 --- a/packages/frontend/src/widgets/WidgetClicker.vue +++ b/packages/frontend/src/widgets/WidgetClicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index 22f053db59..b3128ef27e 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue index a4b90c49d3..fa9a98d571 100644 --- a/packages/frontend/src/widgets/WidgetDigitalClock.vue +++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 9be7d084e9..ae770f9816 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -31,7 +31,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; @@ -62,11 +62,11 @@ const charts = ref<Misskey.entities.ChartsInstanceResponse[]>([]); const fetching = ref(true); const fetch = async () => { - const fetchedInstances = await os.api('federation/instances', { + const fetchedInstances = await misskeyApi('federation/instances', { sort: '+latestRequestReceivedAt', limit: 5, }); - const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + const fetchedCharts = await Promise.all(fetchedInstances.map(i => misskeyApiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); instances.value = fetchedInstances; charts.value = fetchedCharts; fetching.value = false; diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue index 38323ed040..76ccdb3971 100644 --- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue +++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -25,6 +25,7 @@ import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkTagCloud from '@/components/MkTagCloud.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; @@ -56,7 +57,7 @@ function onInstanceClick(i) { } useInterval(() => { - os.api('federation/instances', { + misskeyApi('federation/instances', { sort: '+latestRequestReceivedAt', limit: 25, }).then(res => { diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue index 2133deb363..962521b25c 100644 --- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue +++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index c54682bb87..b3e364a6d7 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -10,19 +10,19 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="values"> <div> <div>Process</div> - <div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div> + <div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }" :title="`${current.inbox.activeSincePrevTick}`">{{ kmg(current.inbox.activeSincePrevTick, 2) }}</div> </div> <div> <div>Active</div> - <div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div> + <div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }" :title="`${current.inbox.active}`">{{ kmg(current.inbox.active, 2) }}</div> </div> <div> <div>Delayed</div> - <div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div> + <div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }" :title="`${current.inbox.delayed}`">{{ kmg(current.inbox.delayed, 2) }}</div> </div> <div> <div>Waiting</div> - <div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div> + <div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }" :title="`${current.inbox.waiting}`">{{ kmg(current.inbox.waiting, 2) }}</div> </div> </div> </div> @@ -31,19 +31,19 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="values"> <div> <div>Process</div> - <div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div> + <div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }" :title="`${current.deliver.activeSincePrevTick}`">{{ kmg(current.deliver.activeSincePrevTick, 2) }}</div> </div> <div> <div>Active</div> - <div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div> + <div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }" :title="`${current.deliver.active}`">{{ kmg(current.deliver.active, 2) }}</div> </div> <div> <div>Delayed</div> - <div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div> + <div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }" :title="`${current.deliver.delayed}`">{{ kmg(current.deliver.delayed, 2) }}</div> </div> <div> <div>Waiting</div> - <div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div> + <div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }" :title="`${current.deliver.waiting}`">{{ kmg(current.deliver.waiting, 2) }}</div> </div> </div> </div> @@ -55,7 +55,7 @@ import { onUnmounted, reactive, ref } from 'vue'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import { useStream } from '@/stream.js'; -import number from '@/filters/number.js'; +import kmg from '@/filters/kmg.js'; import * as sound from '@/scripts/sound.js'; import { deepClone } from '@/scripts/clone.js'; import { defaultStore } from '@/store.js'; @@ -104,10 +104,7 @@ const jammedAudioBuffer = ref<AudioBuffer | null>(null); const jammedSoundNodePlaying = ref<boolean>(false); if (defaultStore.state.sound_masterVolume) { - sound.loadAudio({ - type: 'syuilo/queue-jammed', - volume: 1, - }).then(buf => { + sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => { if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer'); jammedAudioBuffer.value = buf; }); @@ -126,7 +123,7 @@ const onStats = (stats) => { current[domain].delayed = stats[domain].delayed; if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) { - const soundNode = sound.createSourceNode(jammedAudioBuffer.value, 1); + const soundNode = sound.createSourceNode(jammedAudioBuffer.value, {}).soundSource; if (soundNode) { jammedSoundNodePlaying.value = true; soundNode.onended = () => jammedSoundNodePlaying.value = false; diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index 8e9e67ade5..d9efe54623 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index e858741aa1..d590e7768e 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index 0a6fec7f2e..5c89a06c62 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; -import * as os from '@/os.js'; +import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; import number from '@/filters/number.js'; @@ -45,7 +45,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, const onlineUsersCount = ref(0); const tick = () => { - os.apiGet('get-online-users-count').then(res => { + misskeyApiGet('get-online-users-count').then(res => { onlineUsersCount.value = res.count; }); }; diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index ff9b6e19f5..e578ebe2c5 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -28,7 +28,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import { GetFormResultType } from '@/scripts/form.js'; import { useStream } from '@/stream.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -74,7 +74,7 @@ const thumbnail = (image: any): string => { : image.thumbnailUrl; }; -os.api('drive/stream', { +misskeyApi('drive/stream', { type: 'image/*', limit: 9, }).then(res => { diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue index 9979ae256e..7f344505d8 100644 --- a/packages/frontend/src/widgets/WidgetPostForm.vue +++ b/packages/frontend/src/widgets/WidgetPostForm.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue index 3ff57bab86..a5578d4de6 100644 --- a/packages/frontend/src/widgets/WidgetProfile.vue +++ b/packages/frontend/src/widgets/WidgetProfile.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index a718548731..e0272bc7d7 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 607bb2f0ab..7456f9d35f 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetSearch.vue b/packages/frontend/src/widgets/WidgetSearch.vue index 9999139776..cf91a8f089 100644 --- a/packages/frontend/src/widgets/WidgetSearch.vue +++ b/packages/frontend/src/widgets/WidgetSearch.vue @@ -20,8 +20,9 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import MkInput from '@/components/MkInput.vue'; import MkContainer from '@/components/MkContainer.vue'; import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/router/supplier.js'; import { GetFormResultType } from '@/scripts/form.js'; const name = 'search'; @@ -100,7 +101,7 @@ async function search() { if (query == null || query === '') return; if (query.startsWith('https://')) { - const promise = os.api('ap/show', { + const promise = misskeyApi('ap/show', { uri: query, }); diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 7e39a05881..b8efd3bda9 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <p v-if="widgetProps.folderId == null"> {{ i18n.ts.folder }} </p> - <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.t('no-image') }}</p> + <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.ts['no-image'] }}</p> <div ref="slideA" class="slide a"></div> <div ref="slideB" class="slide b"></div> </div> @@ -22,6 +22,7 @@ import * as Misskey from 'misskey-js'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; @@ -77,7 +78,7 @@ const change = () => { const fetch = () => { fetching.value = true; - os.api('drive/files', { + misskeyApi('drive/files', { folderId: widgetProps.folderId, type: 'image/*', limit: 100, @@ -92,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(); }); diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 070466f476..f6cf13290f 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #header> <button class="_button" @click="choose"> - <span>{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : i18n.t('_timelines.' + widgetProps.src) }}</span> + <span>{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : i18n.ts._timelines[widgetProps.src] }}</span> <i :class="menuOpened ? 'ph-caret-up ph-bold ph-lg' : 'ph-caret-down ph-bold ph-lg'" style="margin-left: 8px;"></i> </button> </template> @@ -39,6 +39,7 @@ import { ref } from 'vue'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import { i18n } from '@/i18n.js'; @@ -97,8 +98,8 @@ const setSrc = (src) => { const choose = async (ev) => { menuOpened.value = true; const [antennas, lists] = await Promise.all([ - os.api('antennas/list'), - os.api('users/lists/list'), + misskeyApi('antennas/list'), + misskeyApi('users/lists/list'), ]); const antennaItems = antennas.map(antenna => ({ text: antenna.name, diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 3416a1c0a7..978a1a86f7 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-for="stat in stats" :key="stat.tag"> <div class="tag"> <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> - <p>{{ i18n.t('nUsersMentioned', { n: stat.usersCount }) }}</p> + <p>{{ i18n.tsx.nUsersMentioned({ n: stat.usersCount }) }}</p> </div> <MkMiniChart class="chart" :src="stat.chart"/> </div> @@ -30,7 +30,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; @@ -59,7 +59,7 @@ const stats = ref<Misskey.entities.HashtagsTrendResponse>([]); const fetching = ref(true); const fetch = () => { - os.apiGet('hashtags/trend').then(res => { + misskeyApiGet('hashtags/trend').then(res => { stats.value = res; fetching.value = false; }); diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue index 35f29b5e21..2ac7d1c781 100644 --- a/packages/frontend/src/widgets/WidgetUnixClock.vue +++ b/packages/frontend/src/widgets/WidgetUnixClock.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index c40328d2fa..0e4fe2fbd3 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -30,6 +30,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -64,7 +65,7 @@ const users = ref<Misskey.entities.UserDetailed[]>([]); const fetching = ref(true); async function chooseList() { - const lists = await os.api('users/lists/list'); + const lists = await misskeyApi('users/lists/list'); const { canceled, result: list } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ @@ -85,11 +86,11 @@ const fetch = () => { return; } - os.api('users/lists/show', { + misskeyApi('users/lists/show', { listId: widgetProps.listId, }).then(_list => { list.value = _list; - os.api('users/show', { + misskeyApi('users/show', { userIds: list.value.userIds, }).then(_users => { users.value = _users; diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index b783d783bc..29e4558f1e 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue index f13b6a370d..27d3234207 100644 --- a/packages/frontend/src/widgets/server-metric/cpu-mem.vue +++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -80,13 +80,13 @@ import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; const props = defineProps<{ - connection: any, + connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>, meta: Misskey.entities.ServerInfoResponse }>(); const viewBoxX = ref<number>(50); const viewBoxY = ref<number>(30); -const stats = ref<any[]>([]); +const stats = ref<Misskey.entities.ServerStats[]>([]); const cpuGradientId = uuid(); const cpuMaskId = uuid(); const memGradientId = uuid(); @@ -107,6 +107,7 @@ onMounted(() => { props.connection.on('statsLog', onStatsLog); props.connection.send('requestLog', { id: Math.random().toString().substring(2, 10), + length: 50, }); }); @@ -115,7 +116,7 @@ onBeforeUnmount(() => { props.connection.off('statsLog', onStatsLog); }); -function onStats(connStats) { +function onStats(connStats: Misskey.entities.ServerStats) { stats.value.push(connStats); if (stats.value.length > 50) stats.value.shift(); @@ -136,8 +137,8 @@ function onStats(connStats) { memP.value = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0); } -function onStatsLog(statsLog) { - for (const revStats of [...statsLog].reverse()) { +function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) { + for (const revStats of statsLog.reverse()) { onStats(revStats); } } diff --git a/packages/frontend/src/widgets/server-metric/cpu.vue b/packages/frontend/src/widgets/server-metric/cpu.vue index 35c20c8935..e00ef187f3 100644 --- a/packages/frontend/src/widgets/server-metric/cpu.vue +++ b/packages/frontend/src/widgets/server-metric/cpu.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -20,13 +20,13 @@ import * as Misskey from 'misskey-js'; import XPie from './pie.vue'; const props = defineProps<{ - connection: any, + connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>, meta: Misskey.entities.ServerInfoResponse }>(); const usage = ref<number>(0); -function onStats(stats) { +function onStats(stats: Misskey.entities.ServerStats) { usage.value = stats.cpu; } diff --git a/packages/frontend/src/widgets/server-metric/disk.vue b/packages/frontend/src/widgets/server-metric/disk.vue index 0704854878..e94a8b6848 100644 --- a/packages/frontend/src/widgets/server-metric/disk.vue +++ b/packages/frontend/src/widgets/server-metric/disk.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index 9a785d9112..1180a2a059 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { useWidgetPropsManager, Widget, WidgetComponentExpose } from '../widget.js'; +import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget.js'; import XCpuMemory from './cpu-mem.vue'; import XNet from './net.vue'; import XCpu from './cpu.vue'; @@ -30,7 +30,7 @@ import XMemory from './mem.vue'; import XDisk from './disk.vue'; import MkContainer from '@/components/MkContainer.vue'; import { GetFormResultType } from '@/scripts/form.js'; -import * as os from '@/os.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; @@ -54,11 +54,8 @@ const widgetPropsDef = { type WidgetProps = GetFormResultType<typeof widgetPropsDef>; -// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない -//const props = defineProps<WidgetComponentProps<WidgetProps>>(); -//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); -const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); const { widgetProps, configure, save } = useWidgetPropsManager(name, widgetPropsDef, @@ -68,7 +65,7 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, const meta = ref<Misskey.entities.ServerInfoResponse | null>(null); -os.apiGet('server-info', {}).then(res => { +misskeyApiGet('server-info', {}).then(res => { meta.value = res; }); diff --git a/packages/frontend/src/widgets/server-metric/mem.vue b/packages/frontend/src/widgets/server-metric/mem.vue index 34a1f1ae3d..ba56d14211 100644 --- a/packages/frontend/src/widgets/server-metric/mem.vue +++ b/packages/frontend/src/widgets/server-metric/mem.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -22,7 +22,7 @@ import XPie from './pie.vue'; import bytes from '@/filters/bytes.js'; const props = defineProps<{ - connection: any, + connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>, meta: Misskey.entities.ServerInfoResponse }>(); @@ -31,7 +31,7 @@ const total = ref<number>(0); const used = ref<number>(0); const free = ref<number>(0); -function onStats(stats) { +function onStats(stats: Misskey.entities.ServerStats) { usage.value = stats.mem.active / props.meta.mem.total; total.value = props.meta.mem.total; used.value = stats.mem.active; diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue index 7af88a94eb..d46aaa5f69 100644 --- a/packages/frontend/src/widgets/server-metric/net.vue +++ b/packages/frontend/src/widgets/server-metric/net.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> @@ -54,13 +54,13 @@ import * as Misskey from 'misskey-js'; import bytes from '@/filters/bytes.js'; const props = defineProps<{ - connection: any, + connection: Misskey.ChannelConnection<Misskey.Channels['serverStats']>, meta: Misskey.entities.ServerInfoResponse }>(); const viewBoxX = ref<number>(50); const viewBoxY = ref<number>(30); -const stats = ref<any[]>([]); +const stats = ref<Misskey.entities.ServerStats[]>([]); const inPolylinePoints = ref<string>(''); const outPolylinePoints = ref<string>(''); const inPolygonPoints = ref<string>(''); @@ -77,6 +77,7 @@ onMounted(() => { props.connection.on('statsLog', onStatsLog); props.connection.send('requestLog', { id: Math.random().toString().substring(2, 10), + length: 50, }); }); @@ -85,7 +86,7 @@ onBeforeUnmount(() => { props.connection.off('statsLog', onStatsLog); }); -function onStats(connStats) { +function onStats(connStats: Misskey.entities.ServerStats) { stats.value.push(connStats); if (stats.value.length > 50) stats.value.shift(); @@ -109,8 +110,8 @@ function onStats(connStats) { outRecent.value = connStats.net.tx; } -function onStatsLog(statsLog) { - for (const revStats of [...statsLog].reverse()) { +function onStatsLog(statsLog: Misskey.entities.ServerStatsLog) { + for (const revStats of statsLog.reverse()) { onStats(revStats); } } diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue index fd18a6a4f2..400cbe9fa2 100644 --- a/packages/frontend/src/widgets/server-metric/pie.vue +++ b/packages/frontend/src/widgets/server-metric/pie.vue @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts index 9c7632fc9b..bfe8067adf 100644 --- a/packages/frontend/src/widgets/widget.ts +++ b/packages/frontend/src/widgets/widget.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/workers/draw-blurhash.ts b/packages/frontend/src/workers/draw-blurhash.ts index b919092223..22de6cd3a8 100644 --- a/packages/frontend/src/workers/draw-blurhash.ts +++ b/packages/frontend/src/workers/draw-blurhash.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ diff --git a/packages/frontend/src/workers/test-webgl2.ts b/packages/frontend/src/workers/test-webgl2.ts index 8f57e5039b..b203ebe666 100644 --- a/packages/frontend/src/workers/test-webgl2.ts +++ b/packages/frontend/src/workers/test-webgl2.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ |