diff options
Diffstat (limited to 'packages/frontend/src')
136 files changed, 2235 insertions, 889 deletions
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 13a97e433c..c90cc6bdd0 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -12,7 +12,7 @@ import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; -const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete']; +const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete']; if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) { subBoot(); diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index b91834b94f..36186ecac1 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -5,12 +5,12 @@ import { defineAsyncComponent, reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { apiUrl } from '@@/js/config.js'; +import type { MenuItem, MenuButton } from '@/types/menu.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; -import type { MenuItem, MenuButton } from '@/types/menu.js'; import { del, get, set } from '@/scripts/idb-proxy.js'; -import { apiUrl } from '@@/js/config.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'; @@ -165,7 +165,18 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr }); } -export function updateAccount(accountData: Partial<Account>) { +export function updateAccount(accountData: Account) { + if (!$i) return; + for (const key of Object.keys($i)) { + delete $i[key]; + } + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export function updateAccountPartial(accountData: Partial<Account>) { if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; @@ -224,26 +235,6 @@ export async function openAccountMenu(opts: { }, ev: MouseEvent) { if (!$i) return; - function showSigninDialog() { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - addAccount(res.id, res.i); - success(); - }, - closed: () => dispose(), - }); - } - - function createAccount() { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: (res: Misskey.entities.SignupResponse) => { - addAccount(res.id, res.token); - switchAccountWithToken(res.token); - }, - closed: () => dispose(), - }); - } - async function switchAccount(account: Misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); const found = storedAccounts.find(x => x.id === account.id); @@ -312,10 +303,22 @@ export async function openAccountMenu(opts: { text: i18n.ts.addAccount, children: [{ text: i18n.ts.existingAccount, - action: () => { showSigninDialog(); }, + action: () => { + getAccountWithSigninDialog().then(res => { + if (res != null) { + success(); + } + }); + }, }, { text: i18n.ts.createAccount, - action: () => { createAccount(); }, + action: () => { + getAccountWithSignupDialog().then(res => { + if (res != null) { + switchAccountWithToken(res.token); + } + }); + }, }], }, { type: 'link', @@ -336,6 +339,40 @@ export async function openAccountMenu(opts: { }); } +export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + await addAccount(res.id, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + +export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: async (res: Misskey.entities.SignupResponse) => { + await addAccount(res.id, res.token); + resolve({ id: res.id, token: res.token }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + if (_DEV_) { (window as any).$i = $i; } diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 1145891b71..bfe5c4f5f7 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -11,11 +11,11 @@ import directives from '@/directives/index.js'; import components from '@/components/index.js'; import { applyTheme } from '@/scripts/theme.js'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; -import { updateI18n } from '@/i18n.js'; +import { updateI18n, i18n } from '@/i18n.js'; import { $i, refreshAccount, login } from '@/account.js'; import { defaultStore, ColdDeviceStorage } from '@/store.js'; import { fetchInstance, instance } from '@/instance.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind, updateDeviceKind } from '@/scripts/device-kind.js'; import { reloadChannel } from '@/scripts/unison-reload.js'; import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; import { getAccountFromId } from '@/scripts/get-account-from-id.js'; @@ -185,6 +185,10 @@ export async function common(createVue: () => App<Element>) { } }); + watch(defaultStore.reactiveState.overridedDeviceKind, (kind) => { + updateDeviceKind(kind); + }, { immediate: true }); + watch(defaultStore.reactiveState.useBlurEffectForModal, v => { document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); }, { immediate: true }); @@ -269,6 +273,27 @@ export async function common(createVue: () => App<Element>) { removeSplash(); + //#region Self-XSS 対策メッセージ + console.log( + `%c${i18n.ts._selfXssPrevention.warning}`, + 'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.title}`, + 'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.description1}`, + 'font-size: 16px; font-weight: 700;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.description2}`, + 'font-size: 16px;', + 'font-size: 20px; font-weight: 700; color: #f00;', + ); + console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' })); + //#endregion + return { isClientUpdated, app, diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 76459ab330..2bf9029479 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -4,14 +4,14 @@ */ import { createApp, defineAsyncComponent, markRaw } from 'vue'; +import { ui } from '@@/js/config.js'; import { common } from './common.js'; import type * as Misskey from 'misskey-js'; -import { ui } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/scripts/sound.js'; -import { $i, signout, updateAccount } from '@/account.js'; +import { $i, signout, updateAccountPartial } from '@/account.js'; import { instance } from '@/instance.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; @@ -231,11 +231,41 @@ export async function mainBoot() { } if (!claimedAchievements.includes('justPlainLucky')) { - window.setInterval(() => { + let justPlainLuckyTimer: number | null = null; + let lastVisibilityChangedAt = Date.now(); + + function claimPlainLucky() { + if (document.visibilityState !== 'visible') { + if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer); + return; + } + if (Math.floor(Math.random() * 20000) === 0) { claimAchievement('justPlainLucky'); + } else { + justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10); } - }, 1000 * 10); + } + + window.addEventListener('visibilitychange', () => { + const now = Date.now(); + + if (document.visibilityState === 'visible') { + // タブを高速で切り替えたら取得処理が何度も走るのを防ぐ + if ((now - lastVisibilityChangedAt) < 1000 * 10) { + justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10); + } else { + claimPlainLucky(); + } + } else if (justPlainLuckyTimer != null) { + window.clearTimeout(justPlainLuckyTimer); + justPlainLuckyTimer = null; + } + + lastVisibilityChangedAt = now; + }, { passive: true }); + + claimPlainLucky(); } if (!claimedAchievements.includes('client30min')) { @@ -291,11 +321,11 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { - updateAccount(i); + updateAccountPartial(i); }); main.on('readAllNotifications', () => { - updateAccount({ + updateAccountPartial({ hasUnreadNotification: false, unreadNotificationsCount: 0, }); @@ -303,39 +333,39 @@ export async function mainBoot() { main.on('unreadNotification', () => { const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateAccount({ + updateAccountPartial({ hasUnreadNotification: true, unreadNotificationsCount, }); }); main.on('unreadMention', () => { - updateAccount({ hasUnreadMentions: true }); + updateAccountPartial({ hasUnreadMentions: true }); }); main.on('readAllUnreadMentions', () => { - updateAccount({ hasUnreadMentions: false }); + updateAccountPartial({ hasUnreadMentions: false }); }); main.on('unreadSpecifiedNote', () => { - updateAccount({ hasUnreadSpecifiedNotes: true }); + updateAccountPartial({ hasUnreadSpecifiedNotes: true }); }); main.on('readAllUnreadSpecifiedNotes', () => { - updateAccount({ hasUnreadSpecifiedNotes: false }); + updateAccountPartial({ hasUnreadSpecifiedNotes: false }); }); main.on('readAllAntennas', () => { - updateAccount({ hasUnreadAntenna: false }); + updateAccountPartial({ hasUnreadAntenna: false }); }); main.on('unreadAntenna', () => { - updateAccount({ hasUnreadAntenna: true }); + updateAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); main.on('readAllAnnouncements', () => { - updateAccount({ hasUnreadAnnouncement: false }); + updateAccountPartial({ hasUnreadAnnouncement: false }); }); // 個人宛てお知らせが発行されたとき diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index b9413270ae..e48b6ef781 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <div :class="$style.root" class="_gaps_s"> + <div class="_gaps_s"> <MkFolder :withSpacer="false"> <template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template> <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> @@ -151,6 +151,4 @@ function showMenu(ev: MouseEvent) { </script> <style lang="scss" module> -.root { -} </style> diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 1adb244c9e..3045a47585 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -29,7 +29,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i, updateAccount } from '@/account.js'; +import { $i, updateAccountPartial } from '@/account.js'; const props = withDefaults(defineProps<{ announcement: Misskey.entities.Announcement; @@ -51,7 +51,7 @@ async function ok() { modal.value?.close(); misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); - updateAccount({ + updateAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index 2386ba6fa7..e622d57f1e 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -160,7 +160,7 @@ async function deleteAntenna() { function addUser() { os.selectUser({ includeSelf: true }).then(user => { users.value = users.value.trim(); - users.value += '\n@' + Misskey.acct.toString(user as any); + users.value += '\n@' + Misskey.acct.toString(user); users.value = users.value.trim(); }); } diff --git a/packages/frontend/src/components/MkAuthConfirm.stories.impl.ts b/packages/frontend/src/components/MkAuthConfirm.stories.impl.ts new file mode 100644 index 0000000000..0adc44e204 --- /dev/null +++ b/packages/frontend/src/components/MkAuthConfirm.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkAuthConfirm from './MkAuthConfirm.vue'; +void MkAuthConfirm; diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue new file mode 100644 index 0000000000..f78d2d38f0 --- /dev/null +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -0,0 +1,450 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrapper"> + <Transition + mode="out-in" + :enterActiveClass="$style.transition_enterActive" + :leaveActiveClass="$style.transition_leaveActive" + :enterFromClass="$style.transition_enterFrom" + :leaveToClass="$style.transition_leaveTo" + + :inert="_waiting" + > + <div v-if="phase === 'accountSelect'" key="accountSelect" :class="$style.root" class="_gaps"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-user"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts.pleaseSelectAccount }}</div> + </div> + <div> + <div :class="$style.accountSelectorLabel">{{ i18n.ts.selectAccount }}</div> + <div :class="$style.accountSelectorList"> + <template v-for="[id, user] in users"> + <input :id="'account-' + id" v-model="selectedUser" type="radio" name="accountSelector" :value="id" :class="$style.accountSelectorRadio"/> + <label :for="'account-' + id" :class="$style.accountSelectorItem"> + <MkAvatar :user="user" :class="$style.accountSelectorAvatar"/> + <div :class="$style.accountSelectorBody"> + <MkUserName :user="user" :class="$style.accountSelectorName"/> + <MkAcct :user="user" :class="$style.accountSelectorAcct"/> + </div> + </label> + </template> + <button class="_button" :class="[$style.accountSelectorItem, $style.accountSelectorAddAccountRoot]" @click="clickAddAccount"> + <div :class="[$style.accountSelectorAvatar, $style.accountSelectorAddAccountAvatar]"> + <i class="ti ti-user-plus"></i> + </div> + <div :class="[$style.accountSelectorBody, $style.accountSelectorName]">{{ i18n.ts.addAccount }}</div> + </button> + </div> + </div> + <div class="_buttonsCenter"> + <MkButton rounded gradate :disabled="selectedUser === null" @click="clickChooseAccount">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </div> + <div v-else-if="phase === 'consent'" key="consent" :class="$style.root" class="_gaps"> + <div :class="$style.header" class="_gaps_s"> + <img v-if="icon" :class="$style.icon" :src="getProxiedImageUrl(icon, 'preview')"/> + <div v-else :class="$style.iconFallback"> + <i class="ti ti-apps"></i> + </div> + <div :class="$style.headerText">{{ name ? i18n.tsx._auth.shareAccess({ name }) : i18n.ts._auth.shareAccessAsk }}</div> + </div> + <div v-if="permissions && permissions.length > 0" class="_gaps_s" :class="$style.permissionRoot"> + <div>{{ name ? i18n.tsx._auth.permission({ name }) : i18n.ts._auth.permissionAsk }}</div> + <div :class="$style.permissionListWrapper"> + <ul :class="$style.permissionList"> + <li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> + </ul> + </div> + </div> + <slot name="consentAdditionalInfo"></slot> + <div> + <div :class="$style.accountSelectorLabel"> + {{ i18n.ts._auth.scopeUser }} <button class="_textButton" @click="clickBackToAccountSelect">{{ i18n.ts.switchAccount }}</button> + </div> + <div :class="$style.accountSelectorList"> + <div :class="[$style.accountSelectorItem, $style.static]"> + <MkAvatar :user="users.get(selectedUser!)!" :class="$style.accountSelectorAvatar"/> + <div :class="$style.accountSelectorBody"> + <MkUserName :user="users.get(selectedUser!)!" :class="$style.accountSelectorName"/> + <MkAcct :user="users.get(selectedUser!)!" :class="$style.accountSelectorAcct"/> + </div> + </div> + </div> + </div> + <div class="_buttonsCenter"> + <MkButton rounded @click="clickCancel">{{ i18n.ts.reject }}</MkButton> + <MkButton rounded gradate @click="clickAccept">{{ i18n.ts.accept }}</MkButton> + </div> + </div> + <div v-else-if="phase === 'success'" key="success" :class="$style.root" class="_gaps_s"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-check"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div> + <div :class="$style.headerTextSub">{{ i18n.ts._auth.pleaseGoBack }}</div> + </div> + </div> + <div v-else-if="phase === 'denied'" key="denied" :class="$style.root" class="_gaps_s"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-x"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts._auth.denied }}</div> + </div> + </div> + <div v-else-if="phase === 'failed'" key="failed" :class="$style.root" class="_gaps_s"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-x"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts.somethingHappened }}</div> + </div> + </div> + </Transition> + <div v-if="_waiting" :class="$style.waitingRoot"> + <MkLoading/> + </div> +</div> +</template> + +<script setup lang="ts"> +import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; + +import MkButton from '@/components/MkButton.vue'; + +import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; + +const props = defineProps<{ + name?: string; + icon?: string; + permissions?: (typeof Misskey.permissions[number])[]; + manualWaiting?: boolean; + waitOnDeny?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'accept', token: string): void; + (ev: 'deny', token: string): void; +}>(); + +const waiting = ref(true); +const _waiting = computed(() => waiting.value || props.manualWaiting); +const phase = ref<'accountSelect' | 'consent' | 'success' | 'denied' | 'failed'>('accountSelect'); + +const selectedUser = ref<string | null>(null); + +const users = ref(new Map<string, Misskey.entities.UserDetailed & { token: string; }>()); + +async function init() { + waiting.value = true; + + users.value.clear(); + + if ($i) { + users.value.set($i.id, $i); + } + + const accounts = await getAccounts(); + + const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id)); + + if (accountIdsToFetch.length > 0) { + const usersRes = await misskeyApi('users/show', { + userIds: accountIdsToFetch, + }); + + for (const user of usersRes) { + if (users.value.has(user.id)) continue; + + users.value.set(user.id, { + ...user, + token: accounts.find(a => a.id === user.id)!.token, + }); + } + } + + waiting.value = false; +} + +init(); + +function clickAddAccount(ev: MouseEvent) { + selectedUser.value = null; + + os.popupMenu([{ + text: i18n.ts.existingAccount, + action: () => { + getAccountWithSigninDialog().then(async (res) => { + if (res != null) { + os.success(); + await init(); + if (users.value.has(res.id)) { + selectedUser.value = res.id; + } + } + }); + }, + }, { + text: i18n.ts.createAccount, + action: () => { + getAccountWithSignupDialog().then(async (res) => { + if (res != null) { + os.success(); + await init(); + if (users.value.has(res.id)) { + selectedUser.value = res.id; + } + } + }); + }, + }], ev.currentTarget ?? ev.target); +} + +function clickChooseAccount() { + if (selectedUser.value === null) return; + + phase.value = 'consent'; +} + +function clickBackToAccountSelect() { + selectedUser.value = null; + phase.value = 'accountSelect'; +} + +function clickCancel() { + if (selectedUser.value === null) return; + + const user = users.value.get(selectedUser.value)!; + + const token = user.token; + + if (props.waitOnDeny) { + waiting.value = true; + } + emit('deny', token); +} + +async function clickAccept() { + if (selectedUser.value === null) return; + + const user = users.value.get(selectedUser.value)!; + + const token = user.token; + + waiting.value = true; + emit('accept', token); +} + +function showUI(state: 'success' | 'denied' | 'failed') { + phase.value = state; + waiting.value = false; +} + +defineExpose({ + showUI, +}); +</script> + +<style lang="scss" module> +.transition_enterActive, +.transition_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_leaveTo { + opacity: 0; + transform: translateX(-50px); +} + +.wrapper { + overflow-x: hidden; + overflow-x: clip; + + position: relative; + width: 100%; + height: 100%; +} + +.waitingRoot { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: color-mix(in srgb, var(--MI_THEME-panel), transparent 50%); + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + cursor: wait; +} + +.root { + position: relative; + box-sizing: border-box; + width: 100%; + padding: 48px 24px; +} + +.header { + margin: 0 auto; + max-width: 320px; +} + +.icon, +.iconFallback { + display: block; + margin: 0 auto; + width: 54px; + height: 54px; +} + +.icon { + border-radius: 50%; + border: 1px solid var(--MI_THEME-divider); + background-color: #fff; + object-fit: contain; +} + +.iconFallback { + border-radius: 50%; + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + text-align: center; + line-height: 54px; + font-size: 18px; +} + +.headerText, +.headerTextSub { + text-align: center; + word-break: normal; + word-break: auto-phrase; +} + +.headerText { + font-size: 16px; + font-weight: 700; +} + +.permissionRoot { + padding: 16px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-bg); +} + +.permissionListWrapper { + max-height: 350px; + overflow-y: auto; + padding: 12px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); +} + +.permissionList { + margin: 0 0 0 1.5em; + padding: 0; + font-size: 90%; +} + +.accountSelectorLabel { + font-size: 0.85em; + opacity: 0.7; + margin-bottom: 8px; +} + +.accountSelectorList { + border-radius: var(--MI-radius); + border: 1px solid var(--MI_THEME-divider); + overflow: hidden; + overflow: clip; +} + +.accountSelectorRadio { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; + + &:focus-visible + .accountSelectorItem { + outline: 2px solid var(--MI_THEME-accent); + outline-offset: -4px; + } + + &:checked:focus-visible + .accountSelectorItem { + outline-color: #fff; + } + + &:checked + .accountSelectorItem { + background: var(--MI_THEME-accent); + color: #fff; + } +} + +.accountSelectorItem { + display: flex; + align-items: center; + padding: 8px; + font-size: 14px; + -webkit-tap-highlight-color: transparent; + cursor: pointer; + + &:hover { + background: var(--MI_THEME-buttonHoverBg); + } + + &.static { + cursor: unset; + + &:hover { + background: none; + } + } +} + +.accountSelectorAddAccountRoot { + width: 100%; +} + +.accountSelectorBody { + padding: 0 8px; + min-width: 0; +} + +.accountSelectorAvatar { + width: 45px; + height: 45px; +} + +.accountSelectorAddAccountAvatar { + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + font-size: 16px; + line-height: 45px; + text-align: center; + border-radius: 50%; +} + +.accountSelectorName { + display: block; + font-weight: bold; +} + +.accountSelectorAcct { + opacity: 0.5; +} +</style> diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 82fc89e51c..264cf9af06 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -117,8 +117,8 @@ async function requestRender() { sitekey: props.sitekey, theme: defaultStore.state.darkMode ? 'dark' : 'light', callback: callback, - 'expired-callback': callback, - 'error-callback': callback, + 'expired-callback': () => callback(undefined), + 'error-callback': () => callback(undefined), }); } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { const { default: Widget } = await import('@mcaptcha/vanilla-glue'); diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index 99580df5e2..c470042b79 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -47,11 +47,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; const props = defineProps<{ - channel: Record<string, any>; + channel: Misskey.entities.Channel; }>(); const getLastReadedAt = (): number | null => { diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 8ab01d7db8..f513795c56 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -64,26 +64,30 @@ const showBody = ref(props.expanded); const ignoreOmit = ref(false); const omitted = ref(false); -function enter(el) { +function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; + el.style.height = '0'; el.offsetHeight; // reflow - el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px'; + el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`; } -function afterEnter(el) { - el.style.height = null; +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.height = ''; } -function leave(el) { +function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; + el.style.height = `${elementHeight}px`; el.offsetHeight; // reflow - el.style.height = 0; + el.style.height = '0'; } -function afterLeave(el) { - el.style.height = null; +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.height = ''; } const calcOmit = () => { diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index c2a1aaf29a..0186cfc2c0 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :withOkButton="true" @close="cancel()" @ok="ok()" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header>{{ i18n.ts.cropImage }}</template> <template #default="{ width, height }"> diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index 949adc6a8e..ecbee864dc 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModalWindow ref="dialogEl" @close="cancel()" @closed="$emit('closed')"> +<MkModalWindow ref="dialogEl" @close="cancel()" @closed="emit('closed')"> <template #header>:{{ emoji.name }}:</template> <template #default> <MkSpacer> diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index f04e5cf7c6..9c75f91cb2 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -128,14 +128,14 @@ export default defineComponent({ return children; }; - function onBeforeLeave(element: Element) { - const el = element as HTMLElement; + function onBeforeLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; el.style.top = `${el.offsetTop}px`; el.style.left = `${el.offsetLeft}px`; } - function onLeaveCancelled(element: Element) { - const el = element as HTMLElement; + function onLeaveCancelled(el: Element) { + if (!(el instanceof HTMLElement)) return; el.style.top = ''; el.style.left = ''; } diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 22130d4fab..b095a1cd4a 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only </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> + <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> </div> <div v-if="actions" :class="$style.buttons"> @@ -98,7 +98,7 @@ const props = withDefaults(defineProps<{ text: string; primary?: boolean, danger?: boolean, - callback: (...args: any[]) => void; + callback: (...args: unknown[]) => void; }[]; showOkButton?: boolean; showCancelButton?: boolean; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 23883a44e9..8be6d6f53d 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -157,7 +157,12 @@ const ilFilesObserver = new IntersectionObserver( (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), ); +const sortModeSelect = ref<NonNullable<Misskey.entities.DriveFilesRequest['sort']>>('+createdAt'); + watch(folder, () => emit('cd', folder.value)); +watch(sortModeSelect, () => { + fetch(); +}); function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { addFile(file, true); @@ -193,7 +198,7 @@ function onStreamDriveFolderDeleted(folderId: string) { removeFolder(folderId); } -function onDragover(ev: DragEvent): any { +function onDragover(ev: DragEvent) { if (!ev.dataTransfer) return; // ドラッグ元が自分自身の所有するアイテムだったら @@ -238,7 +243,7 @@ function onDragleave() { draghover.value = false; } -function onDrop(ev: DragEvent): any { +function onDrop(ev: DragEvent) { draghover.value = false; if (!ev.dataTransfer) return; @@ -327,7 +332,7 @@ function createFolder() { title: i18n.ts.createFolder, placeholder: i18n.ts.folderName, }).then(({ canceled, result: name }) => { - if (canceled) return; + if (canceled || name == null) return; misskeyApi('drive/folders/create', { name: name, parentId: folder.value ? folder.value.id : undefined, @@ -558,6 +563,7 @@ async function fetch() { folderId: folder.value ? folder.value.id : null, type: props.type, limit: filesMax + 1, + sort: sortModeSelect.value, }).then(fetchedFiles => { if (fetchedFiles.length === filesMax + 1) { moreFiles.value = true; @@ -607,6 +613,7 @@ function fetchMoreFiles() { type: props.type, untilId: files.value.at(-1)?.id, limit: max + 1, + sort: sortModeSelect.value, }).then(files => { if (files.length === max + 1) { moreFiles.value = true; @@ -642,6 +649,43 @@ function getMenu() { type: 'label', }); + menu.push({ + type: 'parent', + text: i18n.ts.sort, + icon: 'ti ti-arrows-sort', + children: [{ + text: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`, + icon: 'ti ti-sort-descending-letters', + action: () => { sortModeSelect.value = '+createdAt'; }, + active: sortModeSelect.value === '+createdAt', + }, { + text: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`, + icon: 'ti ti-sort-ascending-letters', + action: () => { sortModeSelect.value = '-createdAt'; }, + active: sortModeSelect.value === '-createdAt', + }, { + text: `${i18n.ts.size} (${i18n.ts.descendingOrder})`, + icon: 'ti ti-sort-descending-letters', + action: () => { sortModeSelect.value = '+size'; }, + active: sortModeSelect.value === '+size', + }, { + text: `${i18n.ts.size} (${i18n.ts.ascendingOrder})`, + icon: 'ti ti-sort-ascending-letters', + action: () => { sortModeSelect.value = '-size'; }, + active: sortModeSelect.value === '-size', + }, { + text: `${i18n.ts.name} (${i18n.ts.descendingOrder})`, + icon: 'ti ti-sort-descending-letters', + action: () => { sortModeSelect.value = '+name'; }, + active: sortModeSelect.value === '+name', + }, { + text: `${i18n.ts.name} (${i18n.ts.ascendingOrder})`, + icon: 'ti ti-sort-ascending-letters', + action: () => { sortModeSelect.value = '-name'; }, + active: sortModeSelect.value === '-name', + }], + }); + if (folder.value) { menu.push({ text: i18n.ts.renameFolder, diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index c2bb516c7c..6e9eb75920 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :scroll="false" :withOkButton="false" @close="cancel()" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header><i class="ti ti-code"></i> {{ i18n.ts._embedCodeGen.title }}</template> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index f4caa730bf..b418ed3ae6 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -90,7 +90,7 @@ function computeButtonTitle(ev: MouseEvent): void { elm.title = getEmojiName(emoji); } -function nestedChosen(emoji: any, ev: MouseEvent) { +function nestedChosen(emoji: string, ev: MouseEvent) { emit('chosen', emoji, ev); } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 219950f135..8187d991e7 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -409,7 +409,7 @@ function computeButtonTitle(ev: MouseEvent): void { elm.title = getEmojiName(emoji); } -function chosen(emoji: any, ev?: MouseEvent) { +function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -426,7 +426,7 @@ function chosen(emoji: any, ev?: MouseEvent) { // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { let recents = defaultStore.state.recentlyUsedEmojis; - recents = recents.filter((emoji: any) => emoji !== key); + recents = recents.filter((emoji) => emoji !== key); recents.unshift(key); defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); } diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index b41604b2c3..d59b20435e 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -73,7 +73,7 @@ export type Extension = { author: string; description?: string; permissions?: string[]; - config?: Record<string, any>; + config?: Record<string, unknown>; }; } | { type: 'theme'; diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 1717f8fc98..fb1b5220fb 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" :class="$style.root"> - <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody"> + <header :class="$style.header" class="_button" @click="showBody = !showBody"> <div :class="$style.title"><div><slot name="header"></slot></div></div> <div :class="$style.divider"></div> <button class="_button" :class="$style.button"> @@ -32,21 +32,23 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref, shallowRef, watch } from 'vue'; -import tinycolor from 'tinycolor2'; import { miLocalStorage } from '@/local-storage.js'; import { defaultStore } from '@/store.js'; +import { getBgColor } from '@/scripts/get-bg-color.js'; const miLocalStoragePrefix = 'ui:folder:' as const; const props = withDefaults(defineProps<{ expanded?: boolean; - persistKey?: string; + persistKey?: string | null; }>(), { expanded: true, + persistKey: null, }); -const rootEl = shallowRef<HTMLDivElement>(); -const bg = ref<string>(); +const rootEl = shallowRef<HTMLElement>(); +const parentBg = ref<string | null>(null); +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); watch(showBody, () => { @@ -55,47 +57,34 @@ watch(showBody, () => { } }); -function enter(element: Element) { - const el = element as HTMLElement; +function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementHeight = el.getBoundingClientRect().height; el.style.height = '0'; el.offsetHeight; // reflow - el.style.height = elementHeight + 'px'; + el.style.height = `${elementHeight}px`; } -function afterEnter(element: Element) { - const el = element as HTMLElement; - el.style.height = 'unset'; +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.height = ''; } -function leave(element: Element) { - const el = element as HTMLElement; +function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; + el.style.height = `${elementHeight}px`; el.offsetHeight; // reflow el.style.height = '0'; } -function afterLeave(element: Element) { - const el = element as HTMLElement; - el.style.height = 'unset'; +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.height = ''; } onMounted(() => { - function getParentBg(el?: HTMLElement | null): string { - if (el == null || el.tagName === 'BODY') return 'var(--MI_THEME-bg)'; - const background = el.style.background || el.style.backgroundColor; - if (background) { - return background; - } else { - return getParentBg(el.parentElement); - } - } - - 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(); + parentBg.value = getBgColor(rootEl.value?.parentElement); }); </script> @@ -121,6 +110,7 @@ onMounted(() => { top: var(--MI-stickyTop, 0px); -webkit-backdrop-filter: var(--MI-blur, blur(8px)); backdrop-filter: var(--MI-blur, blur(20px)); + background-color: color(from v-bind("parentBg ?? 'var(--bg)'") srgb r g b / 0.85); } .title { diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 5f9500d923..7bdc06a8b4 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -56,8 +56,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, shallowRef, ref } from 'vue'; +import { nextTick, onMounted, ref, shallowRef } from 'vue'; import { defaultStore } from '@/store.js'; +import { getBgColor } from '@/scripts/get-bg-color.js'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; @@ -69,40 +70,35 @@ const props = withDefaults(defineProps<{ withSpacer: true, }); -const getBgColor = (el: HTMLElement) => { - const style = window.getComputedStyle(el); - if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { - return style.backgroundColor; - } else { - return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; - } -}; - const rootEl = shallowRef<HTMLElement>(); const bgSame = ref(false); const opened = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen); -function enter(el) { +function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; + el.style.height = '0'; el.offsetHeight; // reflow - el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px'; + el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`; } -function afterEnter(el) { - el.style.height = null; +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.height = ''; } -function leave(el) { +function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; + el.style.height = `${elementHeight}px`; el.offsetHeight; // reflow - el.style.height = 0; + el.style.height = '0'; } -function afterLeave(el) { - el.style.height = null; +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.height = ''; } function toggle() { @@ -117,7 +113,7 @@ function toggle() { onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); - const parentBg = getBgColor(rootEl.value!.parentElement!); + const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent'; const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); bgSame.value = parentBg === myBg; }); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ccea7cd453..c1dc67f776 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -37,13 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onBeforeUnmount, onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { host } from '@@/js/config.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 { pleaseLogin } from '@/scripts/please-login.js'; -import { host } from '@@/js/config.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; @@ -80,7 +80,7 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { } async function onClick() { - pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` }); + pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } }); wait.value = true; @@ -91,7 +91,10 @@ async function onClick() { text: i18n.tsx.unfollowConfirm({ name: props.user.name || props.user.username }), }); - if (canceled) return; + if (canceled) { + wait.value = false; + return; + } await misskeyApi('following/delete', { userId: props.user.id, @@ -125,7 +128,10 @@ async function onClick() { }); hasPendingFollowRequestFromYou.value = true; - if ($i == null) return; + if ($i == null) { + wait.value = false; + return; + } claimAchievement('following1'); diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 124f114111..a639eae208 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only @click="cancel()" @ok="ok()" @close="cancel()" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header> {{ title }} diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index e01ff86c5a..08817fd6a8 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs, InputHTMLAttributes } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@@/js/use-interval.js'; @@ -53,7 +53,7 @@ import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; - type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; + type?: InputHTMLAttributes['type']; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -64,8 +64,8 @@ const props = defineProps<{ mfmAutocomplete?: boolean | SuggestionType[], autocapitalize?: string; spellcheck?: boolean; - inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal'; - step?: any; + inputmode?: InputHTMLAttributes['inputmode']; + step?: InputHTMLAttributes['step']; datalist?: string[]; min?: number; max?: number; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index d3a12ca734..65e4a1eb12 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -118,7 +118,7 @@ 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 { exitFullscreen, requestFullscreen } from '@/scripts/fullscreen.js'; import hasAudio from '@/scripts/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/account.js'; @@ -334,26 +334,21 @@ function togglePlayPause() { } 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; - } + if (playerEl.value == null || videoEl.value == null) return; + if (isFullscreen.value) { + exitFullscreen({ + videoEl: videoEl.value, + }); + isFullscreen.value = false; + } else { + requestFullscreen({ + videoEl: videoEl.value, + playerEl: playerEl.value, + options: { + navigationUI: 'hide', + }, + }); + isFullscreen.value = true; } } @@ -454,8 +449,10 @@ watch(loop, (to) => { }); watch(hide, (to) => { - if (to && isFullscreen.value) { - document.exitFullscreen(); + if (videoEl.value && to && isFullscreen.value) { + exitFullscreen({ + videoEl: videoEl.value, + }); isFullscreen.value = false; } }); diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index fe9e1ce088..f06cfffee4 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -26,11 +26,11 @@ import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ - withOkButton: boolean; - withCloseButton: boolean; - okButtonDisabled: boolean; - width: number; - height: number; + withOkButton?: boolean; + withCloseButton?: boolean; + okButtonDisabled?: boolean; + width?: number; + height?: number; }>(), { withOkButton: false, withCloseButton: true, diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 828ad2e872..1a8814b7cb 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -187,6 +187,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; @@ -227,6 +228,7 @@ const emit = defineEmits<{ }>(); const inTimeline = inject<boolean>('inTimeline', false); +const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true)); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); @@ -291,15 +293,18 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string 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(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 (mutedWords != null) { + 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'; + if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) { + return 'sensitiveMute'; + } + return false; } @@ -419,7 +424,7 @@ if (!props.mock) { } function renote(viaKeyboard = false) { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); @@ -429,7 +434,7 @@ function renote(viaKeyboard = false) { } function reply(): void { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); if (props.mock) { return; } @@ -442,7 +447,7 @@ function reply(): void { } function react(): void { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -562,15 +567,24 @@ function showRenoteMenu(): void { }; } + const renoteDetailsMenu: MenuItem = { + type: 'link', + text: i18n.ts.renoteDetails, + icon: 'ti ti-info-circle', + to: notePage(note.value), + }; + if (isMyRenote) { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); os.popupMenu([ + renoteDetailsMenu, getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getUnrenote(), ], renoteTime.value); } else { os.popupMenu([ + renoteDetailsMenu, getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), { type: 'divider' }, getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 6d53685651..4a350388c2 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -62,7 +62,14 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> </div> - <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div> + <div :class="$style.noteHeaderUsernameAndBadgeRoles"> + <div :class="$style.noteHeaderUsername"> + <MkAcct :user="appearNote.user"/> + </div> + <div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles"> + <img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/> + </div> + </div> <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> </div> </header> @@ -207,6 +214,7 @@ import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; +import { host } from '@@/js/config.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -230,7 +238,6 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { host } from '@@/js/config.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; @@ -404,7 +411,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') { } function renote() { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); const { menu } = getRenoteMenu({ note: note.value, renoteButton }); @@ -412,7 +419,7 @@ function renote() { } function reply(): void { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); os.post({ reply: appearNote.value, @@ -423,7 +430,7 @@ function reply(): void { } function react(): void { - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); showMovedDialog(); if (appearNote.value.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -499,7 +506,7 @@ async function clip(): Promise<void> { function showRenoteMenu(): void { if (!isMyRenote) return; - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); os.popupMenu([{ text: i18n.ts.unrenote, icon: 'ti ti-trash', @@ -679,12 +686,30 @@ function loadConversation() { float: right; } +.noteHeaderUsernameAndBadgeRoles { + display: flex; +} + .noteHeaderUsername { margin-bottom: 2px; + margin-right: 0.5em; line-height: 1.3; word-wrap: anywhere; } +.noteHeaderBadgeRoles { + margin: 0 .5em 0 0; +} + +.noteHeaderBadgeRole { + height: 1.3em; + vertical-align: -20%; + + & + .noteHeaderBadgeRole { + margin-left: 0.2em; + } +} + .noteContent { container-type: inline-size; overflow-wrap: break-word; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 47a9c79e45..d07827d11a 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -53,7 +53,7 @@ const props = withDefaults(defineProps<{ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as any); +const typesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as TypesMap); function ok() { emit('done', { diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index dabdd324fd..7fa8c23c6c 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -39,7 +39,7 @@ import number from '@/filters/number.js'; import XValue from '@/components/MkObjectView.value.vue'; const props = defineProps<{ - value: any; + value: unknown; }>(); const collapsed = reactive({}); @@ -50,19 +50,19 @@ if (isObject(props.value)) { } } -function isObject(v): boolean { +function isObject(v: unknown): v is Record<PropertyKey, unknown> { return typeof v === 'object' && !Array.isArray(v) && v !== null; } -function isArray(v): boolean { +function isArray(v: unknown): v is unknown[] { return Array.isArray(v); } -function isEmpty(v): boolean { +function isEmpty(v: unknown): v is Record<PropertyKey, never> | never[] { return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); } -function collapsable(v): boolean { +function collapsable(v: unknown): boolean { return (isObject(v) || isArray(v)) && !isEmpty(v); } </script> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 4777da2848..9547423227 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only :buttonsLeft="buttonsLeft" :buttonsRight="buttonsRight" :contextmenu="contextmenu" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header> <template v-if="pageMetadata"> @@ -30,17 +30,17 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { url } from '@@/js/config.js'; +import { getScrollContainer } from '@@/js/scroll.js'; 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 '@@/js/config.js'; import { useScrollPositionManager } from '@/nirax.js'; import { i18n } from '@/i18n.js'; import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { getScrollContainer } from '@@/js/scroll.js'; import { useRouterFactory } from '@/router/supplier.js'; import { mainRouter } from '@/router/main.js'; @@ -48,7 +48,7 @@ const props = defineProps<{ initialPath: string; }>(); -defineEmits<{ +const emit = defineEmits<{ (ev: 'closed'): void; }>(); @@ -58,7 +58,7 @@ const windowRouter = routerFactory(props.initialPath); 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; }[]>([{ +const history = ref<{ path: string; key: string; }[]>([{ path: windowRouter.getCurrentPath(), key: windowRouter.getCurrentKey(), }]); diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 48913004e0..e70ac7ff1a 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { host } from '@@/js/config.js'; +import { useInterval } from '@@/js/use-interval.js'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.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 { host } from '@@/js/config.js'; -import { useInterval } from '@@/js/use-interval.js'; const props = defineProps<{ noteId: string; @@ -85,7 +85,7 @@ if (props.poll.expiresAt) { const vote = async (id) => { if (props.readOnly || closed.value || isVoted.value) return; - pleaseLogin(undefined, pleaseLoginContext.value); + pleaseLogin({ openOnRemote: pleaseLoginContext.value }); const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 26c251a8d2..df664e49f7 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -19,7 +19,7 @@ defineProps<{ items: MenuItem[]; align?: 'center' | string; width?: number; - src?: any; + src?: HTMLElement | null; returnFocusTo?: HTMLElement | null; }>(); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 76a6e4212a..0b5794d1e3 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -65,10 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> - <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> + <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> - <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> @@ -129,25 +129,13 @@ import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; +import type { PostFormProps } from '@/types/post-form.js'; const $i = signinRequired(); const modal = inject('modal'); -const props = withDefaults(defineProps<{ - reply?: Misskey.entities.Note; - renote?: Misskey.entities.Note; - channel?: Misskey.entities.Channel; // TODO - mention?: 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.UserDetailed[]; - initialNote?: Misskey.entities.Note; - instant?: boolean; +const props = withDefaults(defineProps<PostFormProps & { fixed?: boolean; autofocus?: boolean; freezeAfterPosted?: boolean; @@ -201,6 +189,7 @@ const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]' const imeText = ref(''); const showingOptions = ref(false); const textAreaReadOnly = ref(false); +const justEndedComposition = ref(false); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -573,7 +562,13 @@ function clear() { function onKeydown(ev: KeyboardEvent) { if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post(); - if (ev.key === 'Escape') emit('esc'); + // justEndedComposition.value is for Safari, which keyDown occurs after compositionend. + // ev.isComposing is for another browsers. + if (ev.key === 'Escape' && !justEndedComposition.value && !ev.isComposing) emit('esc'); +} + +function onKeyup(ev: KeyboardEvent) { + justEndedComposition.value = false; } function onCompositionUpdate(ev: CompositionEvent) { @@ -582,6 +577,7 @@ function onCompositionUpdate(ev: CompositionEvent) { function onCompositionEnd(ev: CompositionEvent) { imeText.value = ''; + justEndedComposition.value = true; } async function onPaste(ev: ClipboardEvent) { @@ -947,8 +943,8 @@ function showActions(ev: MouseEvent) { action.handler({ text: text.value, cw: cw.value, - }, (key, value: any) => { - if (typeof key !== 'string') return; + }, (key, value) => { + if (typeof key !== 'string' || typeof value !== 'string') return; if (key === 'text') { text.value = value; } if (key === 'cw') { useCw.value = value !== null; cw.value = value; } }); @@ -1112,7 +1108,7 @@ defineExpose({ &:focus-visible { outline: none; - .submitInner { + > .submitInner { outline: 2px solid var(--MI_THEME-fgOnAccent); outline-offset: -4px; } @@ -1127,13 +1123,13 @@ defineExpose({ } &:not(:disabled):hover { - > .inner { + > .submitInner { background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } } &:not(:disabled):active { - > .inner { + > .submitInner { background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index ee7038df64..56e026aa3c 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-show="props.modelValue.length != 0" :class="$style.root"> <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{element}"> + <template #item="{ element }"> <div :class="$style.file" role="button" @@ -38,14 +38,14 @@ import type { MenuItem } from '@/types/menu.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const props = defineProps<{ - modelValue: any[]; + modelValue: Misskey.entities.DriveFile[]; detachMediaFn?: (id: string) => void; }>(); const mock = inject<boolean>('mock', false); const emit = defineEmits<{ - (ev: 'update:modelValue', value: any[]): void; + (ev: 'update:modelValue', value: Misskey.entities.DriveFile[]): void; (ev: 'detach', id: string): void; (ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void; (ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void; @@ -113,7 +113,7 @@ async function rename(file) { }); } -async function describe(file) { +async function describe(file: Misskey.entities.DriveFile) { if (mock) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index d6bca29050..32d8df1504 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -11,23 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef } from 'vue'; -import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; +import type { PostFormProps } from '@/types/post-form.js'; -const props = withDefaults(defineProps<{ - reply?: Misskey.entities.Note; - renote?: Misskey.entities.Note; - channel?: any; // TODO - mention?: 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.UserDetailed[]; - initialNote?: Misskey.entities.Note; +const props = withDefaults(defineProps<PostFormProps & { instant?: boolean; fixed?: boolean; autofocus?: boolean; diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index e735d9fff8..f16c8f6c2a 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -24,17 +24,17 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> +<script lang="ts" setup generic="T extends unknown"> import { computed } from 'vue'; const props = defineProps<{ - modelValue: any; - value: any; + modelValue: T; + value: T; disabled?: boolean; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: any): void; + (ev: 'update:modelValue', value: T): void; }>(); const checked = computed(() => props.modelValue === props.value); diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index f4c3643ba8..d24e0b15bf 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; +import * as Misskey from 'misskey-js'; import { getEmojiName } from '@@/js/emojilist.js'; import MkTooltip from './MkTooltip.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; @@ -30,7 +31,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; defineProps<{ showing: boolean; reaction: string; - users: any[]; // TODO + users: Misskey.entities.UserLite[]; count: number; targetElement: HTMLElement; }>(); diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index a2ec384ac5..eeadd49936 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only @keydown.space.enter="show" > <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> - <select + <div ref="inputEl" - v-model="v" v-adaptive-border tabindex="-1" :class="$style.inputCore" @@ -26,55 +25,48 @@ SPDX-License-Identifier: AGPL-3.0-only :required="required" :readonly="readonly" :placeholder="placeholder" - @input="onInput" @mousedown.prevent="() => {}" @keydown.prevent="() => {}" > - <slot></slot> - </select> + <div style="pointer-events: none;">{{ currentValueText ?? '' }}</div> + <div style="display: none;"> + <slot></slot> + </div> + </div> <div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div> </div> <div :class="$style.caption"><slot name="caption"></slot></div> - - <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> 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 '@@/js/use-interval.js'; -import { i18n } from '@/i18n.js'; import type { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; const props = defineProps<{ - modelValue: string | null; + modelValue: string | number | null; required?: boolean; readonly?: boolean; disabled?: boolean; placeholder?: string; autofocus?: boolean; inline?: boolean; - manualSave?: boolean; small?: boolean; large?: boolean; }>(); const emit = defineEmits<{ - (ev: 'changeByUser', value: string | null): void; - (ev: 'update:modelValue', value: string | null): void; + (ev: 'update:modelValue', value: string | number | null): void; }>(); const slots = useSlots(); const { modelValue, autofocus } = toRefs(props); -const v = ref(modelValue.value); const focused = ref(false); const opening = ref(false); -const changed = ref(false); -const invalid = ref(false); -const filled = computed(() => v.value !== '' && v.value != null); +const currentValueText = ref<string | null>(null); const inputEl = ref<HTMLObjectElement | null>(null); const prefixEl = ref<HTMLElement | null>(null); const suffixEl = ref<HTMLElement | null>(null); @@ -85,26 +77,6 @@ const height = 36; const focus = () => container.value?.focus(); -const onInput = (ev) => { - changed.value = true; -}; - -const updated = () => { - changed.value = false; - emit('update:modelValue', v.value); -}; - -watch(modelValue, newValue => { - v.value = newValue; -}); - -watch(v, () => { - if (!props.manualSave) { - updated(); - } - - invalid.value = inputEl.value?.validity.badInput ?? true; -}); // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する @@ -134,6 +106,31 @@ onMounted(() => { }); }); +watch(modelValue, () => { + 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; + if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + if (Array.isArray(fragment.children)) scanOptions(fragment.children); + } else if (vnode.props == null) { // v-if で条件が false のときにこうなる + // nop? + } else { + const option = vnode; + if (option.props?.value === modelValue.value) { + currentValueText.value = option.children as string; + break; + } + } + } + }; + + scanOptions(slots.default!()); +}, { immediate: true }); + function show() { if (opening.value) return; focus(); @@ -146,11 +143,9 @@ function show() { const pushOption = (option: VNode) => { menu.push({ text: option.children as string, - active: computed(() => v.value === option.props?.value), + active: computed(() => modelValue.value === option.props?.value), action: () => { - v.value = option.props?.value; - changed.value = true; - emit('changeByUser', v.value); + emit('update:modelValue', option.props?.value); }, }); }; @@ -248,7 +243,8 @@ function show() { .inputCore { appearance: none; -webkit-appearance: none; - display: block; + display: flex; + align-items: center; height: v-bind("height + 'px'"); width: 100%; margin: 0; diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue index 5608122a39..cd003a39df 100644 --- a/packages/frontend/src/components/MkSignin.password.vue +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -24,11 +24,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <div v-if="needCaptcha"> - <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"/> - <MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/> + <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> + <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> + <MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha"/> </div> <MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 3d1c44fc90..e1f4e26d62 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -277,7 +277,7 @@ async function onSubmit(): Promise<void> { return null; }); - if (res) { + if (res && res.ok) { if (res.status === 204 || instance.emailRequiredForSignup) { os.alert({ type: 'success', @@ -295,6 +295,8 @@ async function onSubmit(): Promise<void> { await login(resJson.token); } } + } else { + onSignupApiError(); } submitting.value = false; diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index f240e6dc46..6fb9d77837 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -8,8 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="500" :height="600" - @close="dialog?.close()" - @closed="$emit('closed')" + @close="onClose" + @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="onClose"/> </template> <template v-else> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> @@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'done', res: Misskey.entities.SignupResponse): void; + (ev: 'cancelled'): void; (ev: 'closed'): void; }>(); @@ -55,6 +56,11 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const isAcceptedServerRule = ref(false); +function onClose() { + emit('cancelled'); + dialog.value?.close(); +} + function onSignup(res: Misskey.entities.SignupResponse) { emit('done', res); dialog.value?.close(); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 6e7a875dec..0caaed6f39 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -28,11 +28,38 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> -import { } from 'vue'; +<script lang="ts"> +export type SuperMenuDef = { + title?: string; + items: ({ + type: 'a'; + href: string; + target?: string; + icon?: string; + text: string; + danger?: boolean; + active?: boolean; + } | { + type: 'button'; + icon?: string; + text: string; + danger?: boolean; + active?: boolean; + action: (ev: MouseEvent) => void; + } | { + type: 'link'; + to: string; + icon?: string; + text: string; + danger?: boolean; + active?: boolean; + })[]; +}; +</script> +<script lang="ts" setup> defineProps<{ - def: any[]; + def: SuperMenuDef[]; grid?: boolean; }>(); </script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index ca87316bf7..fb8eb4ae37 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -38,10 +38,12 @@ const props = withDefaults(defineProps<{ sound?: boolean; withRenotes?: boolean; withReplies?: boolean; + withSensitive?: boolean; onlyFiles?: boolean; }>(), { withRenotes: true, withReplies: false, + withSensitive: true, onlyFiles: false, }); @@ -51,6 +53,7 @@ const emit = defineEmits<{ }>(); provide('inTimeline', true); +provide('tl_withSensitive', computed(() => props.withSensitive)); provide('inChannel', computed(() => props.src === 'channel')); type TimelineQueryType = { @@ -248,6 +251,9 @@ function refreshEndpointAndChannel() { // IDが切り替わったら切り替え先のTLを表示させたい watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); +// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK +watch(() => props.withSensitive, reloadTimeline); + // 初回表示用 refreshEndpointAndChannel(); diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index a7bc3f37f1..73aef68964 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :okButtonDisabled="false" :canClose="false" @close="dialog?.close()" - @closed="$emit('closed')" + @closed="emit('closed')" @ok="ok()" > <template #header>{{ title || i18n.ts.generateAccessToken }}</template> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index c287effadc..f0da8fd3f2 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -180,7 +180,7 @@ window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLa sensitive.value = info.sensitive ?? false; }); -function adjustTweetHeight(message: any) { +function adjustTweetHeight(message: MessageEvent) { if (message.origin !== 'https://platform.twitter.com') return; const embed = message.data?.['twttr.embed']; if (embed?.method !== 'twttr.private.resize') return; @@ -193,14 +193,16 @@ function openPlayer(): void { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), { url: requestUrl.href, }, { - // TODO + closed: () => { + dispose(); + }, }); } -(window as any).addEventListener('message', adjustTweetHeight); +window.addEventListener('message', adjustTweetHeight); onUnmounted(() => { - (window as any).removeEventListener('message', adjustTweetHeight); + window.removeEventListener('message', adjustTweetHeight); }); </script> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 7a2b5f5ddc..fe499fabbf 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="400" @close="dialog?.close()" - @closed="$emit('closed')" + @closed="emit('closed')" > <template v-if="announcement" #header>:{{ announcement.title }}:</template> <template v-else #header>New announcement</template> @@ -62,9 +62,16 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; +type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; } + const props = defineProps<{ user: Misskey.entities.User, - announcement?: Misskey.entities.Announcement, + announcement?: Required<AdminAnnouncementType>, +}>(); + +const emit = defineEmits<{ + (ev: 'done', v: { deleted?: boolean; updated?: AdminAnnouncementType; created?: AdminAnnouncementType; }): void, + (ev: 'closed'): void }>(); const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null); @@ -74,11 +81,6 @@ 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<{ - (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, - (ev: 'closed'): void -}>(); - async function done() { const params = { title: title.value, @@ -88,7 +90,7 @@ async function done() { display: display.value, needConfirmationToRead: needConfirmationToRead.value, userId: props.user.id, - }; + } satisfies Misskey.entities.AdminAnnouncementsCreateRequest; if (props.announcement) { await os.apiWithDialog('admin/announcements/update', { diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 8e58a6c5a2..764bf74f21 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only @click="cancel()" @close="cancel()" @ok="ok()" - @closed="$emit('closed')" + @closed="emit('closed')" > <template #header>{{ i18n.ts.selectUser }}</template> <div> diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue index 054a503257..0cb7f22e93 100644 --- a/packages/frontend/src/components/MkUsersTooltip.vue +++ b/packages/frontend/src/components/MkUsersTooltip.vue @@ -16,12 +16,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import * as Misskey from 'misskey-js'; import MkTooltip from './MkTooltip.vue'; defineProps<{ showing: boolean; - users: any[]; // TODO + users: Misskey.entities.UserLite[]; count: number; targetElement: HTMLElement; }>(); diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 492dd4cdc0..ba619f6063 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <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="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> + <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> </header> <Sortable :modelValue="props.widgets" diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 056b6a37ed..2953f656d4 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only :enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" appear - @afterLeave="$emit('closed')" + @afterLeave="emit('closed')" > <div v-if="showing" ref="rootEl" :class="[$style.root, { [$style.maximized]: maximized }]"> <div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown"> @@ -60,6 +60,13 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; +type WindowButton = { + title: string; + icon: string; + onClick: () => void; + highlighted?: boolean; +}; + const minHeight = 50; const minWidth = 250; @@ -87,8 +94,8 @@ const props = withDefaults(defineProps<{ mini?: boolean; front?: boolean; contextmenu?: MenuItem[] | null; - buttonsLeft?: any[]; - buttonsRight?: any[]; + buttonsLeft?: WindowButton[]; + buttonsRight?: WindowButton[]; }>(), { initialWidth: 400, initialHeight: null, diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 5226c61d68..821f07510b 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -18,19 +18,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup> +<script lang="ts" setup generic="T extends unknown"> import { ref, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - p: () => Promise<any>; + p: () => Promise<T>; }>(); const pending = ref(true); const resolved = ref(false); const rejected = ref(false); -const result = ref<any>(null); +const result = ref<T | null>(null); const process = () => { if (props.p == null) { diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 0d68d02e35..08a78c8d81 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="chosen && !shouldHide" :class="$style.root"> +<div v-if="chosen && !shouldHide"> <div v-if="!showMenu" :class="[$style.main, { @@ -120,10 +120,6 @@ function reduceFrequency(): void { </script> <style lang="scss" module> -.root { - -} - .main { text-align: center; diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 66f82a7898..ec1d859080 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -25,17 +25,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, ref } from 'vue'; +import { computed, defineAsyncComponent, inject, ref } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; 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 { misskeyApi, 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'; -import type { MenuItem } from '@/types/menu.js'; +import { $i } from '@/account.js'; const props = defineProps<{ name: string; @@ -125,9 +126,31 @@ function onClick(ev: MouseEvent) { }, }); + if ($i?.isModerator ?? $i?.isAdmin) { + menuItems.push({ + text: i18n.ts.edit, + icon: 'ti ti-pencil', + action: async () => { + await edit(props.name); + }, + }); + } + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } } + +async function edit(name: string) { + const emoji = await misskeyApi('emoji', { + name: name, + }); + const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), { + emoji: emoji, + }, { + closed: () => dispose(), + }); +} + </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 0d4ae8cacb..0d138d1f1c 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -467,8 +467,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } default: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - console.error('unrecognized ast type:', (token as any).type); + // @ts-expect-error 存在しないASTタイプ + console.error('unrecognized ast type:', token.type); return []; } diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index adf8638dae..aaef8b8fca 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -53,7 +53,7 @@ export type Tab = { </script> <script lang="ts" setup> -import { onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue'; +import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; import { defaultStore } from '@/store.js'; const props = withDefaults(defineProps<{ @@ -120,14 +120,14 @@ function onTabWheel(ev: WheelEvent) { let entering = false; -async function enter(element: Element) { +async function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; entering = true; - const el = element as HTMLElement; const elementWidth = el.getBoundingClientRect().width; el.style.width = '0'; el.style.paddingLeft = '0'; - el.offsetWidth; // force reflow - el.style.width = elementWidth + 'px'; + el.offsetWidth; // reflow + el.style.width = `${elementWidth}px`; el.style.paddingLeft = ''; nextTick(() => { entering = false; @@ -136,22 +136,23 @@ async function enter(element: Element) { setTimeout(renderTab, 170); } -function afterEnter(element: Element) { - //el.style.width = ''; +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + // element.style.width = ''; } -async function leave(element: Element) { - const el = element as HTMLElement; +async function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; const elementWidth = el.getBoundingClientRect().width; - el.style.width = elementWidth + 'px'; + el.style.width = `${elementWidth}px`; el.style.paddingLeft = ''; - el.offsetWidth; // force reflow + el.offsetWidth; // reflow el.style.width = '0'; el.style.paddingLeft = '0'; } -function afterLeave(element: Element) { - const el = element as HTMLElement; +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; el.style.width = ''; } diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts index 45891de889..f88996019f 100644 --- a/packages/frontend/src/directives/adaptive-bg.ts +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -4,19 +4,11 @@ */ import { Directive } from 'vue'; +import { getBgColor } from '@/scripts/get-bg-color.js'; export default { mounted(src, binding, vn) { - const getBgColor = (el: HTMLElement) => { - const style = window.getComputedStyle(el); - if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { - return style.backgroundColor; - } else { - return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; - } - }; - - const parentBg = getBgColor(src.parentElement); + const parentBg = getBgColor(src.parentElement) ?? 'transparent'; const myBg = window.getComputedStyle(src).backgroundColor; diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts index 685ca38e96..1305f312bd 100644 --- a/packages/frontend/src/directives/adaptive-border.ts +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -4,19 +4,11 @@ */ import { Directive } from 'vue'; +import { getBgColor } from '@/scripts/get-bg-color.js'; export default { mounted(src, binding, vn) { - const getBgColor = (el: HTMLElement) => { - const style = window.getComputedStyle(el); - if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { - return style.backgroundColor; - } else { - return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; - } - }; - - const parentBg = getBgColor(src.parentElement); + const parentBg = getBgColor(src.parentElement) ?? 'transparent'; const myBg = window.getComputedStyle(src).backgroundColor; diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts index 7b5969c679..aa26b94d0b 100644 --- a/packages/frontend/src/directives/panel.ts +++ b/packages/frontend/src/directives/panel.ts @@ -4,19 +4,11 @@ */ import { Directive } from 'vue'; +import { getBgColor } from '@/scripts/get-bg-color.js'; export default { mounted(src, binding, vn) { - const getBgColor = (el: HTMLElement) => { - const style = window.getComputedStyle(el); - if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { - return style.backgroundColor; - } else { - return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; - } - }; - - const parentBg = getBgColor(src.parentElement); + const parentBg = getBgColor(src.parentElement) ?? 'transparent'; const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index ac730f8021..096d404a57 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -40,7 +40,6 @@ export const navbarItemDef = reactive({ followRequests: { title: i18n.ts.followRequests, icon: 'ti ti-user-plus', - show: computed(() => $i != null && $i.isLocked), indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), to: '/my/follow-requests', }, diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index 25f853453a..965bd6f0bc 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -36,6 +36,8 @@ interface RouteDefWithRedirect extends RouteDefBase { export type RouteDef = RouteDefWithComponent | RouteDefWithRedirect; +export type RouterFlag = 'forcePage'; + type ParsedPath = (string | { name: string; startsWith?: string; @@ -107,7 +109,7 @@ export interface IRouter extends EventEmitter<RouterEvent> { current: Resolved; currentRef: ShallowRef<Resolved>; currentRoute: ShallowRef<RouteDef>; - navHook: ((path: string, flag?: any) => boolean) | null; + navHook: ((path: string, flag?: RouterFlag) => boolean) | null; /** * ルートの初期化(eventListenerの定義後に必ず呼び出すこと) @@ -116,11 +118,11 @@ export interface IRouter extends EventEmitter<RouterEvent> { resolve(path: string): Resolved | null; - getCurrentPath(): any; + getCurrentPath(): string; getCurrentKey(): string; - push(path: string, flag?: any): void; + push(path: string, flag?: RouterFlag): void; replace(path: string, key?: string | null): void; @@ -197,7 +199,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { private currentKey = Date.now().toString(); private redirectCount = 0; - public navHook: ((path: string, flag?: any) => boolean) | null = null; + public navHook: ((path: string, flag?: RouterFlag) => boolean) | null = null; constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { super(); @@ -404,7 +406,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { return this.currentKey; } - public push(path: string, flag?: any) { + public push(path: string, flag?: RouterFlag) { const beforePath = this.currentPath; if (path === beforePath) { this.emit('same'); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 4d41cf5bc0..ea1b673de9 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -28,12 +28,13 @@ import { pleaseLogin } from '@/scripts/please-login.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; import { focusParent } from '@/scripts/focus.js'; +import type { PostFormProps } from '@/types/post-form.js'; export const openingWindowsCount = ref(0); -export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( +export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( endpoint: E, - data: P = {} as any, + data: P, token?: string | null | undefined, customErrors?: Record<string, { title?: string; text: string; }>, ) => { @@ -94,7 +95,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey export function promiseDialog<T extends Promise<any>>( promise: T, - onSuccess?: ((res: any) => void) | null, + onSuccess?: ((res: Awaited<T>) => void) | null, onFailure?: ((err: Misskey.api.APIError) => void) | null, text?: string, ): T { @@ -136,12 +137,12 @@ export function promiseDialog<T extends Promise<any>>( } let popupIdCount = 0; -export const popups = ref([]) as Ref<{ +export const popups = ref<{ id: number; component: Component; props: Record<string, any>; events: Record<string, any>; -}[]>; +}[]>([]); const zIndexes = { veryLow: 500000, @@ -458,7 +459,7 @@ type SelectItem<C> = { }; // default が指定されていたら result は null になり得ないことを保証する overload function -export function select<C = any>(props: { +export function select<C = unknown>(props: { title?: string; text?: string; default: string; @@ -471,7 +472,7 @@ export function select<C = any>(props: { } | { canceled: false; result: C; }>; -export function select<C = any>(props: { +export function select<C = unknown>(props: { title?: string; text?: string; default?: string | null; @@ -484,7 +485,7 @@ export function select<C = any>(props: { } | { canceled: false; result: C | null; }>; -export function select<C = any>(props: { +export function select<C = unknown>(props: { title?: string; text?: string; default?: string | null; @@ -687,15 +688,17 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { })); } -export function post(props: Record<string, any> = {}): Promise<void> { - pleaseLogin(undefined, (props.initialText || props.initialNote ? { - type: 'share', - params: { - text: props.initialText ?? props.initialNote.text, - visibility: props.initialVisibility ?? props.initialNote?.visibility, - localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0', - }, - } : undefined)); +export function post(props: PostFormProps = {}): Promise<void> { + pleaseLogin({ + openOnRemote: (props.initialText || props.initialNote ? { + type: 'share', + params: { + text: props.initialText ?? props.initialNote?.text ?? '', + visibility: props.initialVisibility ?? props.initialNote?.visibility ?? 'public', + localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0', + }, + } : undefined), + }); showMovedDialog(); return new Promise(resolve => { diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 68b98c2ab7..f2becfd8f5 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -269,6 +269,12 @@ const patronsWithIcon = [{ }, { name: '如月ユカ', icon: 'https://assets.misskey-hub.net/patrons/f24a042076a041b6811a2f124eb620ca.jpg', +}, { + name: 'Yatoigawa', + icon: 'https://assets.misskey-hub.net/patrons/505e3568885a4a488431a8f22b4553d0.jpg', +}, { + name: '秋瀬カヲル', + icon: 'https://assets.misskey-hub.net/patrons/0f22aeb866484f4fa51db6721e3f9847.jpg', }]; const patrons = [ @@ -375,6 +381,9 @@ const patrons = [ 'はとぽぷさん', '100の人 (エスパー・イーシア)', 'ケモナーのケシン', + 'こまつぶり', + 'まゆつな空高', + 'asata', ]; const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 948e7a3cce..30d7e38638 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -627,6 +627,7 @@ definePageMetadata(() => ({ <style lang="scss" module> .ip { display: flex; + word-break: break-all; > :global(.date) { opacity: 0.7; diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 8a206a2f79..fd15ae1d66 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <MkInfo v-if="thereIsUnresolvedAbuseReport" warn>{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> <MkInfo v-if="noMaintainerInformation" warn>{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/moderation" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noBotProtection" warn>{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> </div> diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 5d8a581b2e..ac1fe7783c 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -10,9 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> - <MkSwitch v-model="enableRegistration" @change="onChange_enableRegistration"> - <template #label>{{ i18n.ts.enableRegistration }}</template> - <template #caption>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</template> + <MkSwitch :modelValue="enableRegistration" @update:modelValue="onChange_enableRegistration"> + <template #label>{{ i18n.ts._serverSettings.openRegistration }}</template> + <template #caption> + <div>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.openRegistrationWarning }}</div> + </template> </MkSwitch> <MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup"> @@ -164,7 +167,17 @@ async function init() { mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n'); } -function onChange_enableRegistration(value: boolean) { +async function onChange_enableRegistration(value: boolean) { + if (value) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.acknowledgeNotesAndEnable, + }); + if (canceled) return; + } + + enableRegistration.value = value; + os.apiWithDialog('admin/update-meta', { disableRegistration: !value, }).then(() => { diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index d1bbb5b734..870c3ce88b 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -99,19 +99,19 @@ async function addUser() { const { canceled: canceled1, result: username } = await os.inputText({ title: i18n.ts.username, }); - if (canceled1) return; + if (canceled1 || username == null) return; const { canceled: canceled2, result: password } = await os.inputText({ title: i18n.ts.password, type: 'password', }); - if (canceled2) return; + if (canceled2 || password == null) return; os.apiWithDialog('admin/accounts/create', { username: username, password: password, }).then(res => { - paginationComponent.value.reload(); + paginationComponent.value?.reload(); }); } diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue index 3840e6a494..56c10fb292 100644 --- a/packages/frontend/src/pages/announcement.vue +++ b/packages/frontend/src/pages/announcement.vue @@ -55,7 +55,7 @@ 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'; +import { $i, updateAccountPartial } from '@/account.js'; import { defaultStore } from '@/store.js'; const props = defineProps<{ @@ -90,7 +90,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> { target.isRead = true; await misskeyApi('i/read-announcement', { announcementId: target.id }); if ($i) { - updateAccount({ + updateAccountPartial({ unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), }); } @@ -103,7 +103,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); definePageMetadata(() => ({ - title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements, + title: announcement.value ? announcement.value.title : i18n.ts.announcements, icon: 'ti ti-speakerphone', })); </script> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 688a542988..75c0fd98dc 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -56,7 +56,7 @@ 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'; +import { $i, updateAccountPartial } from '@/account.js'; const paginationCurrent = { endpoint: 'announcements' as const, @@ -94,7 +94,7 @@ async function read(target) { return a; }); misskeyApi('i/read-announcement', { announcementId: target.id }); - updateAccount({ + updateAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), }); } diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index d8f8d0b428..4170b4f73e 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -62,7 +62,7 @@ function accepted() { state.value = 'accepted'; if (session.value && session.value.app.callbackUrl) { const url = new URL(session.value.app.callbackUrl); - if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url'); + if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(url.protocol)) throw new Error('invalid url'); location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`; } } diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue new file mode 100644 index 0000000000..a834f1c5fd --- /dev/null +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -0,0 +1,220 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkWindow + ref="windowEl" + :initialWidth="400" + :initialHeight="500" + :canResize="true" + @close="windowEl?.close()" + @closed="emit('closed')" +> + <template v-if="avatarDecoration" #header>{{ avatarDecoration.name }}</template> + <template v-else #header>New decoration</template> + + <div style="display: flex; flex-direction: column; min-height: 100%;"> + <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <div class="_gaps_m"> + <div :class="$style.preview"> + <div :class="[$style.previewItem, $style.light]"> + <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="url != '' ? [{ url }] : []" forceShowDecoration/> + </div> + <div :class="[$style.previewItem, $style.dark]"> + <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="url != '' ? [{ url }] : []" forceShowDecoration/> + </div> + </div> + <MkInput v-model="name"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkInput v-model="url"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <MkTextarea v-model="description"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + <MkFolder> + <template #label>{{ i18n.ts.availableRoles }}</template> + <template #suffix>{{ rolesThatCanBeUsedThisDecoration.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisDecoration.length }}</template> + + <div class="_gaps"> + <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <div v-for="role in rolesThatCanBeUsedThisDecoration" :key="role.id" :class="$style.roleItem"> + <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> + </div> + </div> + </MkFolder> + <MkButton v-if="avatarDecoration" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </MkSpacer> + <div :class="$style.footer"> + <MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.avatarDecoration ? i18n.ts.update : i18n.ts.create }}</MkButton> + </div> + </div> +</MkWindow> +</template> + +<script lang="ts" setup> +import { computed, watch, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +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 MkSwitch from '@/components/MkSwitch.vue'; +import MkRolePreview from '@/components/MkRolePreview.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import { signinRequired } from '@/account.js'; + +const $i = signinRequired(); + +const props = defineProps<{ + avatarDecoration?: any, +}>(); + +const emit = defineEmits<{ + (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, + (ev: 'closed'): void +}>(); + +const windowEl = ref<InstanceType<typeof MkWindow> | null>(null); +const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : ''); +const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : ''); +const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : ''); +const roleIdsThatCanBeUsedThisDecoration = ref(props.avatarDecoration ? props.avatarDecoration.roleIdsThatCanBeUsedThisDecoration : []); +const rolesThatCanBeUsedThisDecoration = ref<Misskey.entities.Role[]>([]); + +watch(roleIdsThatCanBeUsedThisDecoration, async () => { + rolesThatCanBeUsedThisDecoration.value = (await Promise.all(roleIdsThatCanBeUsedThisDecoration.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null); +}, { immediate: true }); + +async function addRole() { + const roles = await misskeyApi('admin/roles/list'); + const currentRoleIds = rolesThatCanBeUsedThisDecoration.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 || role == null) return; + + rolesThatCanBeUsedThisDecoration.value.push(role); +} + +async function removeRole(role, ev) { + rolesThatCanBeUsedThisDecoration.value = rolesThatCanBeUsedThisDecoration.value.filter(x => x.id !== role.id); +} + +async function done() { + const params = { + url: url.value, + name: name.value, + description: description.value, + roleIdsThatCanBeUsedThisDecoration: rolesThatCanBeUsedThisDecoration.value.map(x => x.id), + }; + + if (props.avatarDecoration) { + await os.apiWithDialog('admin/avatar-decorations/update', { + id: props.avatarDecoration.id, + ...params, + }); + + emit('done', { + updated: { + id: props.avatarDecoration.id, + ...params, + }, + }); + + windowEl.value?.close(); + } else { + const created = await os.apiWithDialog('admin/avatar-decorations/create', params); + + emit('done', { + created: created, + }); + + windowEl.value?.close(); + } +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.tsx.removeAreYouSure({ x: name.value }), + }); + if (canceled) return; + + misskeyApi('admin/avatar-decorations/delete', { + id: props.avatarDecoration.id, + }).then(() => { + emit('done', { + deleted: true, + }); + windowEl.value?.close(); + }); +} +</script> + +<style lang="scss" module> +.preview { + display: grid; + place-items: center; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + gap: var(--MI-margin); +} + +.previewItem { + width: 100%; + height: 100%; + min-height: 160px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--MI-radius); + + &.light { + background: #eee; + } + + &.dark { + background: #222; + } +} + +.roleItem { + display: flex; +} + +.role { + flex: 1; +} + +.roleUnassign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} + +.footer { + position: sticky; + z-index: 10000; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-acrylicBg); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); +} +</style> diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue index b97e7c0eea..a5cafb1678 100644 --- a/packages/frontend/src/pages/avatar-decorations.vue +++ b/packages/frontend/src/pages/avatar-decorations.vue @@ -5,92 +5,38 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="900"> <div class="_gaps"> - <MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null"> - <template #label>{{ avatarDecoration.name }}</template> - <template #caption>{{ avatarDecoration.description }}</template> - - <div :class="$style.editorRoot"> - <div :class="$style.editorWrapper"> - <div :class="$style.preview"> - <div :class="[$style.previewItem, $style.light]"> - <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[avatarDecoration]" forceShowDecoration/> - </div> - <div :class="[$style.previewItem, $style.dark]"> - <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[avatarDecoration]" forceShowDecoration/> - </div> - </div> - <div class="_gaps_m"> - <MkInput v-model="avatarDecoration.name"> - <template #label>{{ i18n.ts.name }}</template> - </MkInput> - <MkTextarea v-model="avatarDecoration.description"> - <template #label>{{ i18n.ts.description }}</template> - </MkTextarea> - <MkInput v-model="avatarDecoration.url"> - <template #label>{{ i18n.ts.imageUrl }}</template> - </MkInput> - <div class="_buttons"> - <MkButton inline primary @click="save(avatarDecoration)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - <MkButton v-if="avatarDecoration.id != null" inline danger @click="del(avatarDecoration)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> - </div> - </div> - </div> + <div :class="$style.decorations"> + <div + v-for="avatarDecoration in avatarDecorations" + :key="avatarDecoration.id" + v-panel + :class="$style.decoration" + @click="edit(avatarDecoration)" + > + <div :class="$style.decorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div> + <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/> </div> - </MkFolder> + </div> </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; import { signinRequired } from '@/account.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 MkFolder from '@/components/MkFolder.vue'; - -const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]); const $i = signinRequired(); -function add() { - avatarDecorations.value.unshift({ - _id: Math.random().toString(36), - id: null, - name: '', - description: '', - url: '', - }); -} - -function del(avatarDecoration) { - os.confirm({ - type: 'warning', - text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }), - }).then(({ canceled }) => { - if (canceled) return; - avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration); - misskeyApi('admin/avatar-decorations/delete', avatarDecoration); - }); -} - -async function save(avatarDecoration) { - if (avatarDecoration.id == null) { - await os.apiWithDialog('admin/avatar-decorations/create', avatarDecoration); - load(); - } else { - os.apiWithDialog('admin/avatar-decorations/update', avatarDecoration); - } -} +const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]); function load() { misskeyApi('admin/avatar-decorations/list').then(_avatarDecorations => { @@ -100,6 +46,37 @@ function load() { load(); +async function add(ev: MouseEvent) { + const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), { + }, { + done: result => { + if (result.created) { + avatarDecorations.value.unshift(result.created); + } + }, + closed: () => dispose(), + }); +} + +function edit(avatarDecoration) { + const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), { + avatarDecoration: avatarDecoration, + }, { + done: result => { + if (result.updated) { + const index = avatarDecorations.value.findIndex(x => x.id === avatarDecoration.id); + avatarDecorations.value[index] = { + ...avatarDecorations.value[index], + ...result.updated, + }; + } else if (result.deleted) { + avatarDecorations.value = avatarDecorations.value.filter(x => x.id !== avatarDecoration.id); + } + }, + closed: () => dispose(), + }); +} + const headerActions = computed(() => [{ asFullButton: true, icon: 'ti ti-plus', @@ -116,53 +93,26 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> -.editorRoot { - container: editor / inline-size; -} - -.editorWrapper { +.decorations { display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto auto; - gap: var(--MI-margin); + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-gap: 12px; } -.preview { - display: grid; - place-items: center; - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr; - gap: var(--MI-margin); -} - -.previewItem { - width: 100%; - height: 100%; - min-height: 160px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--MI-radius); - - &.light { - background: #eee; - } - - &.dark { - background: #222; - } +.decoration { + cursor: pointer; + padding: 16px 16px 28px 16px; + border-radius: 8px; + text-align: center; + font-size: 90%; + overflow: clip; + contain: content; } -@container editor (min-width: 600px) { - .editorWrapper { - grid-template-columns: 200px 1fr; - grid-template-rows: 1fr; - gap: calc(var(--MI-margin) * 2); - } - - .preview { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } +.decorationName { + position: relative; + z-index: 10; + font-weight: bold; + margin-bottom: 20px; } </style> diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 7b1737fece..891d59d605 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -33,25 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, provide, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; 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 '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import { clipsCache } from '@/cache.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { genEmbedCode } from '@/scripts/get-embed-code.js'; -import type { MenuItem } from '@/types/menu.js'; +import { getServerContext } from '@/server-context.js'; + +const CTX_CLIP = getServerContext('clip'); const props = defineProps<{ clipId: string, }>(); -const clip = ref<Misskey.entities.Clip | null>(null); +const clip = ref<Misskey.entities.Clip | null>(CTX_CLIP); const favorited = ref(false); const pagination = { endpoint: 'clips/notes' as const, @@ -64,6 +67,11 @@ const pagination = { const isOwned = computed<boolean | null>(() => $i && clip.value && ($i.id === clip.value.userId)); watch(() => props.clipId, async () => { + if (CTX_CLIP && CTX_CLIP.id === props.clipId) { + clip.value = CTX_CLIP; + return; + } + clip.value = await misskeyApi('clips/show', { clipId: props.clipId, }); diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 1e416e22d3..cae3f3ede9 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -116,7 +116,7 @@ const selectAll = () => { if (selectedEmojis.value.length > 0) { selectedEmojis.value = []; } else { - selectedEmojis.value = Array.from(emojisPaginationComponent.value.items.values(), item => item.id); + selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id); } }; @@ -133,7 +133,7 @@ const add = async (ev: MouseEvent) => { }, { done: result => { if (result.created) { - emojisPaginationComponent.value.prepend(result.created); + emojisPaginationComponent.value?.prepend(result.created); } }, closed: () => dispose(), @@ -146,12 +146,12 @@ const edit = (emoji) => { }, { done: result => { if (result.updated) { - emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ + emojisPaginationComponent.value?.updateItem(result.updated.id, (oldEmoji) => ({ ...oldEmoji, ...result.updated, })); } else if (result.deleted) { - emojisPaginationComponent.value.removeItem(emoji.id); + emojisPaginationComponent.value?.removeItem(emoji.id); } }, closed: () => dispose(), @@ -226,7 +226,7 @@ const setCategoryBulk = async () => { ids: selectedEmojis.value, category: result, }); - emojisPaginationComponent.value.reload(); + emojisPaginationComponent.value?.reload(); }; const setLicenseBulk = async () => { @@ -238,43 +238,43 @@ const setLicenseBulk = async () => { ids: selectedEmojis.value, license: result, }); - emojisPaginationComponent.value.reload(); + emojisPaginationComponent.value?.reload(); }; const addTagBulk = async () => { const { canceled, result } = await os.inputText({ title: 'Tag', }); - if (canceled) return; + if (canceled || result == null) return; await os.apiWithDialog('admin/emoji/add-aliases-bulk', { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value.reload(); + emojisPaginationComponent.value?.reload(); }; const removeTagBulk = async () => { const { canceled, result } = await os.inputText({ title: 'Tag', }); - if (canceled) return; + if (canceled || result == null) return; await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value.reload(); + emojisPaginationComponent.value?.reload(); }; const setTagBulk = async () => { const { canceled, result } = await os.inputText({ title: 'Tag', }); - if (canceled) return; + if (canceled || result == null) return; await os.apiWithDialog('admin/emoji/set-aliases-bulk', { ids: selectedEmojis.value, aliases: result.split(' '), }); - emojisPaginationComponent.value.reload(); + emojisPaginationComponent.value?.reload(); }; const delBulk = async () => { @@ -286,7 +286,7 @@ const delBulk = async () => { await os.apiWithDialog('admin/emoji/delete-bulk', { ids: selectedEmojis.value, }); - emojisPaginationComponent.value.reload(); + emojisPaginationComponent.value?.reload(); }; const headerActions = computed(() => [{ diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 969aa6bbf7..3765319b25 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only ref="windowEl" :initialWidth="400" :initialHeight="500" - :canResize="false" - @close="windowEl.close()" - @closed="$emit('closed')" + :canResize="true" + @close="windowEl?.close()" + @closed="emit('closed')" > <template v-if="emoji" #header>:{{ emoji.name }}:</template> <template v-else #header>New emoji</template> @@ -95,14 +95,19 @@ import { selectFile } from '@/scripts/select-file.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ - emoji?: any, + emoji?: Misskey.entities.EmojiDetailed, +}>(); + +const emit = defineEmits<{ + (ev: 'done', v: { deleted?: boolean; updated?: Misskey.entities.AdminEmojiUpdateRequest; created?: Misskey.entities.AdminEmojiUpdateRequest }): void, + (ev: 'closed'): void }>(); 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 category = ref<string>(props.emoji?.category ? props.emoji.category : ''); const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : ''); -const license = ref<string>(props.emoji ? (props.emoji.license ?? '') : ''); +const license = ref<string>(props.emoji?.license ? props.emoji.license : ''); const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false); const localOnly = ref(props.emoji ? props.emoji.localOnly : false); const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []); @@ -115,12 +120,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => { const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null); -const emit = defineEmits<{ - (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, - (ev: 'closed'): void -}>(); - -async function changeImage(ev) { +async function changeImage(ev: Event) { file.value = await selectFile(ev.currentTarget ?? ev.target, null); const candidate = file.value.name.replace(/\.(.+)$/, ''); if (candidate.match(/^[a-z0-9_]+$/)) { @@ -140,7 +140,7 @@ async function addRole() { rolesThatCanBeUsedThisEmojiAsReaction.value.push(role); } -async function removeRole(role, ev) { +async function removeRole(role: Misskey.entities.RoleLite, ev: Event) { rolesThatCanBeUsedThisEmojiAsReaction.value = rolesThatCanBeUsedThisEmojiAsReaction.value.filter(x => x.id !== role.id); } @@ -172,7 +172,7 @@ async function done() { }, }); - windowEl.value.close(); + windowEl.value?.close(); } else { const created = await os.apiWithDialog('admin/emoji/add', params); @@ -180,11 +180,12 @@ async function done() { created: created, }); - windowEl.value.close(); + windowEl.value?.close(); } } async function del() { + if (!props.emoji) return; const { canceled } = await os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: name.value }), @@ -197,7 +198,7 @@ async function del() { emit('done', { deleted: true, }); - windowEl.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 fcd22155b7..979d50966e 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -15,18 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; +import { defineAsyncComponent } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.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'; +import { $i } from '@/account.js'; const props = defineProps<{ emoji: Misskey.entities.EmojiSimple; }>(); function menu(ev) { - os.popupMenu([{ + const menuItems: MenuItem[] = []; + menuItems.push({ type: 'label', text: ':' + props.emoji.name + ':', }, { @@ -48,8 +52,28 @@ function menu(ev) { closed: () => dispose(), }); }, - }], ev.currentTarget ?? ev.target); + }); + + if ($i?.isModerator ?? $i?.isAdmin) { + menuItems.push({ + text: i18n.ts.edit, + icon: 'ti ti-pencil', + action: () => { + edit(props.emoji); + }, + }); + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } + +const edit = async (emoji) => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), { + emoji: emoji, + }, { + closed: () => dispose(), + }); +}; </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 8991af8086..5d819e7993 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -5,69 +5,104 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> - <template #header><MkPageHeader/></template> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="800"> - <MkPagination ref="paginationComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noFollowRequests }}</div> - </div> - </template> - <template #default="{items}"> - <div class="mk-follow-requests"> - <div v-for="req in items" :key="req.id" class="user _panel"> - <MkAvatar class="avatar" :user="req.follower" indicator link preview/> - <div class="body"> - <div class="name"> - <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> - <p class="acct">@{{ acct(req.follower) }}</p> - </div> - <div class="commands"> - <MkButton class="command" rounded primary @click="accept(req.follower)"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> - <MkButton class="command" rounded danger @click="reject(req.follower)"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <div :key="tab" class="_gaps"> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" class="_ghost"/> + <div>{{ i18n.ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests _gaps"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA> + <p class="acct">@{{ acct(displayUser(req)) }}</p> + </div> + <div v-if="tab === 'list'" class="commands"> + <MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> + <MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> + </div> + <div v-else class="commands"> + <MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.cancel }}</MkButton> + </div> + </div> </div> </div> - </div> - </div> - </template> - </MkPagination> + </template> + </MkPagination> + </div> + </MkHorizontalSwipe> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { shallowRef, computed } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; +import * as Misskey from 'misskey-js'; +import { shallowRef, computed, ref } from 'vue'; +import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { infoImageUrl } from '@/instance.js'; +import { $i } from '@/account.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); -const pagination = { - endpoint: 'following/requests/list' as const, +const pagination = computed<Paging>(() => tab.value === 'list' ? { + endpoint: 'following/requests/list', + limit: 10, +} : { + endpoint: 'following/requests/sent', limit: 10, -}; +}); -function accept(user) { - misskeyApi('following/requests/accept', { userId: user.id }).then(() => { - paginationComponent.value.reload(); +function accept(user: Misskey.entities.UserLite) { + os.apiWithDialog('following/requests/accept', { userId: user.id }).then(() => { + paginationComponent.value?.reload(); }); } -function reject(user) { - misskeyApi('following/requests/reject', { userId: user.id }).then(() => { - paginationComponent.value.reload(); +function reject(user: Misskey.entities.UserLite) { + os.apiWithDialog('following/requests/reject', { userId: user.id }).then(() => { + paginationComponent.value?.reload(); }); } +function cancel(user: Misskey.entities.UserLite) { + os.apiWithDialog('following/requests/cancel', { userId: user.id }).then(() => { + paginationComponent.value?.reload(); + }); +} + +function displayUser(req) { + return tab.value === 'list' ? req.follower : req.followee; +} + const headerActions = computed(() => []); -const headerTabs = computed(() => []); +const headerTabs = computed(() => [ + { + key: 'list', + title: i18n.ts._followRequest.recieved, + icon: 'ti ti-download', + }, { + key: 'sent', + title: i18n.ts._followRequest.sent, + icon: 'ti ti-upload', + }, +]); + +const tab = ref($i?.isLocked ? 'list' : 'sent'); definePageMetadata(() => ({ title: i18n.ts.followRequests, diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 25e56d2b8d..3f6ae27b89 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -5,10 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> - <template #header> - <MkPageHeader/> - </template> - <MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200"> + <template #header><MkPageHeader/></template> + <MkSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200"> <div :class="$style.root"> <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> <div :class="$style.text"> @@ -16,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.nothing }} </div> </div> - </MKSpacer> + </MkSpacer> <MkSpacer v-else :contentMax="800"> <div class="_gaps_m" style="text-align: center;"> <div v-if="resetCycle && inviteLimit">{{ i18n.tsx.inviteLimitResetCycle({ time: resetCycle, limit: inviteLimit }) }}</div> diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index 48bc568ac4..0ff1854154 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200"> + <MkSpacer v-if="error != null" :contentMax="1200"> <div :class="$style.root"> <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> <p :class="$style.text"> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.nothing }} </p> </div> - </MKSpacer> + </MkSpacer> <MkSpacer v-else-if="list" :contentMax="700" :class="$style.main"> <div v-if="list" class="members _margin"> <div :class="$style.member_text">{{ i18n.ts.members }}</div> @@ -50,7 +50,7 @@ const props = defineProps<{ }>(); const list = ref<Misskey.entities.UserList | null>(null); -const error = ref(); +const error = ref<unknown | null>(null); const users = ref<Misskey.entities.UserDetailed[]>([]); function fetchList(): void { diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue index 3233953942..6f10c69640 100644 --- a/packages/frontend/src/pages/lookup.vue +++ b/packages/frontend/src/pages/lookup.vue @@ -40,7 +40,7 @@ function fetch() { return; } - let promise: Promise<any>; + let promise: Promise<unknown>; if (uri.startsWith('https://')) { promise = misskeyApi('ap/show', { diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index ffaf739ed0..e85d2c29c1 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -4,95 +4,79 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="800"> - <div v-if="$i"> - <div v-if="state == 'waiting'"> - <MkLoading/> - </div> - <div v-if="state == 'denied'"> - <p>{{ i18n.ts._auth.denied }}</p> - </div> - <div v-else-if="state == 'accepted'" class="accepted"> - <p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> - <p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p> - </div> - <div v-else> - <div v-if="_permissions.length > 0"> - <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.ts._permissions[p] }}</li> - </ul> - </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> - <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> - </div> - </div> +<div> + <MkAnimBg style="position: fixed; top: 0;"/> + <div :class="$style.formContainer"> + <div :class="$style.form"> + <MkAuthConfirm + ref="authRoot" + :name="name" + :icon="icon || undefined" + :permissions="_permissions" + @accept="onAccept" + @deny="onDeny" + > + <template #consentAdditionalInfo> + <div v-if="callback != null" class="_gaps_s" :class="$style.redirectRoot"> + <div>{{ i18n.ts._auth.byClickingYouWillBeRedirectedToThisUrl }}</div> + <div class="_monospace" :class="$style.redirectUrl">{{ callback }}</div> + </div> + </template> + </MkAuthConfirm> </div> - <div v-else> - <p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> - <MkSignin @login="onLogin"/> - </div> - </MkSpacer> -</MkStickyContainer> + </div> +</div> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; -import MkSignin from '@/components/MkSignin.vue'; -import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, login } from '@/account.js'; +import { computed, useTemplateRef } from 'vue'; +import * as Misskey from 'misskey-js'; + +import MkAnimBg from '@/components/MkAnimBg.vue'; +import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; + import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; const props = defineProps<{ session: string; callback?: string; - name: string; - icon: string; - permission: string; // コンマ区切り + name?: string; + icon?: string; + permission?: string; // コンマ区切り }>(); -const _permissions = props.permission ? props.permission.split(',') : []; +const _permissions = computed(() => { + return (props.permission ? props.permission.split(',').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) : []); +}); -const state = ref<string | null>(null); +const authRoot = useTemplateRef('authRoot'); -async function accept(): Promise<void> { - state.value = 'waiting'; +async function onAccept(token: string) { await misskeyApi('miauth/gen-token', { session: props.session, name: props.name, iconUrl: props.icon, - permission: _permissions, + permission: _permissions.value, + }, token).catch(() => { + authRoot.value?.showUI('failed'); }); - state.value = 'accepted'; - if (props.callback) { + if (props.callback && props.callback !== '') { const cbUrl = new URL(props.callback); - if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url'); + if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url'); cbUrl.searchParams.set('session', props.session); - location.href = cbUrl.href; + location.href = cbUrl.toString(); + } else { + authRoot.value?.showUI('success'); } } -function deny(): void { - state.value = 'denied'; -} - -function onLogin(res): void { - login(res.i); +function onDeny() { + authRoot.value?.showUI('denied'); } -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - definePageMetadata(() => ({ title: 'MiAuth', icon: 'ti ti-apps', @@ -100,15 +84,38 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> -.buttons { - margin-top: 16px; - display: flex; - gap: 8px; - flex-wrap: wrap; +.formContainer { + min-height: 100svh; + padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px; + box-sizing: border-box; + display: grid; + place-content: center; +} + +.form { + position: relative; + z-index: 10; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: clip; + max-width: 500px; + width: calc(100vw - 64px); + height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px))); + overflow-y: scroll; +} + +.redirectRoot { + padding: 16px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-bg); } -.loginMessage { - text-align: center; - margin: 8px 0 24px; +.redirectUrl { + font-size: 90%; + padding: 12px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); + overflow-x: scroll; } </style> diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index ece998a7a5..acf37a9a2f 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -77,15 +77,15 @@ async function create() { clipsCache.delete(); - pagingComponent.value.reload(); + pagingComponent.value?.reload(); } function onClipCreated() { - pagingComponent.value.reload(); + pagingComponent.value?.reload(); } function onClipDeleted() { - pagingComponent.value.reload(); + pagingComponent.value?.reload(); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 804a5ae8f8..69e404bd85 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -110,7 +110,7 @@ function addUser() { listId: list.value.id, userId: user.id, }).then(() => { - paginationEl.value.reload(); + paginationEl.value?.reload(); }); }); } @@ -126,7 +126,7 @@ async function removeUser(item, ev) { listId: list.value.id, userId: item.userId, }).then(() => { - paginationEl.value.removeItem(item.id); + paginationEl.value?.removeItem(item.id); }); }, }], ev.currentTarget ?? ev.target); diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index 93a792c42f..6a2d01b6fa 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -24,7 +24,7 @@ const props = defineProps<{ }>(); if (props.showLoginPopup) { - pleaseLogin('/'); + pleaseLogin({ path: '/' }); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 448244204d..3e1d04bd6d 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -61,13 +61,17 @@ import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; import MkClipPreview from '@/components/MkClipPreview.vue'; import { defaultStore } from '@/store.js'; +import { pleaseLogin } from '@/scripts/please-login.js'; +import { getServerContext } from '@/server-context.js'; + +const CTX_NOTE = getServerContext('note'); const props = defineProps<{ noteId: string; initialTab?: string; }>(); -const note = ref<null | Misskey.entities.Note>(); +const note = ref<null | Misskey.entities.Note>(CTX_NOTE); const clips = ref<Misskey.entities.Clip[]>(); const showPrev = ref<'user' | 'channel' | false>(false); const showNext = ref<'user' | 'channel' | false>(false); @@ -115,6 +119,12 @@ function fetchNote() { showPrev.value = false; showNext.value = false; note.value = null; + + if (CTX_NOTE && CTX_NOTE.id === props.noteId) { + note.value = CTX_NOTE; + return; + } + misskeyApi('notes/show', { noteId: props.noteId, }).then(res => { @@ -128,6 +138,11 @@ function fetchNote() { }); } }).catch(err => { + if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') { + pleaseLogin({ + message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor, + }); + } error.value = err; }); } diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 733e34eb2c..8719a769e5 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -4,40 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - <MkSpacer :contentMax="800"> - <div v-if="$i"> - <div v-if="permissions.length > 0"> - <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.ts._permissions[p] }}</li> - </ul> - </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"/> - <input name="transaction_id" type="hidden" :value="transactionIdMeta?.content"/> - <MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton> - <MkButton inline primary>{{ i18n.ts.accept }}</MkButton> - </form> +<div> + <MkAnimBg style="position: fixed; top: 0;"/> + <div :class="$style.formContainer"> + <div :class="$style.form"> + <MkAuthConfirm + ref="authRoot" + :name="name" + :permissions="permissions" + :waitOnDeny="true" + @accept="onAccept" + @deny="onDeny" + /> </div> - <div v-else> - <p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> - <MkSignin @login="onLogin"/> - </div> - </MkSpacer> -</MkStickyContainer> + </div> +</div> </template> <script lang="ts" setup> -import MkSignin from '@/components/MkSignin.vue'; -import MkButton from '@/components/MkButton.vue'; -import { $i, login } from '@/account.js'; -import { i18n } from '@/i18n.js'; +import * as Misskey from 'misskey-js'; +import MkAnimBg from '@/components/MkAnimBg.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); if (transactionIdMeta) { @@ -45,10 +33,44 @@ if (transactionIdMeta) { } const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; -const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? []; +const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? []; + +function doPost(token: string, decision: 'accept' | 'deny') { + const form = document.createElement('form'); + form.action = '/oauth/decision'; + form.method = 'post'; + form.acceptCharset = 'utf-8'; + + const loginToken = document.createElement('input'); + loginToken.type = 'hidden'; + loginToken.name = 'login_token'; + loginToken.value = token; + form.appendChild(loginToken); + + const transactionId = document.createElement('input'); + transactionId.type = 'hidden'; + transactionId.name = 'transaction_id'; + transactionId.value = transactionIdMeta?.content ?? ''; + form.appendChild(transactionId); + + if (decision === 'deny') { + const cancel = document.createElement('input'); + cancel.type = 'hidden'; + cancel.name = 'cancel'; + cancel.value = 'cancel'; + form.appendChild(cancel); + } + + document.body.appendChild(form); + form.submit(); +} + +function onAccept(token: string) { + doPost(token, 'accept'); +} -function onLogin(res): void { - login(res.i); +function onDeny(token: string) { + doPost(token, 'deny'); } definePageMetadata(() => ({ @@ -58,15 +80,24 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> -.buttons { - margin-top: 16px; - display: flex; - gap: 8px; - flex-wrap: wrap; +.formContainer { + min-height: 100svh; + padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px; + box-sizing: border-box; + display: grid; + place-content: center; } -.loginMessage { - text-align: center; - margin: 8px 0 24px; +.form { + position: relative; + z-index: 10; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: clip; + max-width: 500px; + width: calc(100vw - 64px); + height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px))); + overflow-y: scroll; } </style> 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 1cfe7a6d2d..c3ad6657b0 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 @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <!-- eslint-disable vue/no-mutating-props --> -<XContainer :draggable="true" @remove="() => $emit('remove')"> +<XContainer :draggable="true" @remove="() => emit('remove')"> <template #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template> <template #func> <button @click="choose()"> @@ -30,11 +30,12 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - modelValue: any + modelValue: Misskey.entities.PageBlock & { type: 'image' }; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: any): void; + (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'image' }): void; + (ev: 'remove'): void; }>(); const file = ref<Misskey.entities.DriveFile | null>(null); 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 0a28386986..36e03b4790 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 @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <!-- eslint-disable vue/no-mutating-props --> -<XContainer :draggable="true" @remove="() => $emit('remove')"> +<XContainer :draggable="true" @remove="() => emit('remove')"> <template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template> <section style="padding: 16px;" class="_gaps_s"> @@ -34,19 +34,24 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - modelValue: any + modelValue: Misskey.entities.PageBlock & { type: 'note' }; }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: any): void; + (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void; }>(); -const id = ref<any>(props.modelValue.note); +const id = ref(props.modelValue.note); const note = ref<Misskey.entities.Note | null>(null); watch(id, async () => { if (id.value && (id.value.startsWith('http://') || id.value.startsWith('https://'))) { - id.value = (id.value.endsWith('/') ? id.value.slice(0, -1) : id.value).split('/').pop(); + id.value = (id.value.endsWith('/') ? id.value.slice(0, -1) : id.value).split('/').pop() ?? null; + } + + if (!id.value) { + note.value = null; + return; } emit('update:modelValue', { 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 0f8dc33143..3fed07f7e8 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 @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <!-- eslint-disable vue/no-mutating-props --> -<XContainer :draggable="true" @remove="() => $emit('remove')"> +<XContainer :draggable="true" @remove="() => emit('remove')"> <template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template> <template #func> <button class="_button" @click="rename()"> @@ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -/* eslint-disable vue/no-mutating-props */ + import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; import XContainer from '../page-editor.container.vue'; import * as os from '@/os.js'; @@ -33,14 +34,13 @@ import { getPageBlockList } from '@/pages/page-editor/common.js'; const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); -const props = withDefaults(defineProps<{ - modelValue: any, -}>(), { - modelValue: {}, -}); +const props = defineProps<{ + modelValue: Misskey.entities.PageBlock & { type: 'section'; }, +}>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: any): void; + (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'section' }): void; + (ev: 'remove'): void; }>(); const children = ref(deepClone(props.modelValue.children ?? [])); 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 f09f7e1acd..5795b46c00 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 @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <!-- eslint-disable vue/no-mutating-props --> -<XContainer :draggable="true" @remove="() => $emit('remove')"> +<XContainer :draggable="true" @remove="() => emit('remove')"> <template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template> <section> @@ -15,18 +15,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -/* eslint-disable vue/no-mutating-props */ + import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue'; +import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import { i18n } from '@/i18n.js'; import { Autocomplete } from '@/scripts/autocomplete.js'; const props = defineProps<{ - modelValue: any + modelValue: Misskey.entities.PageBlock & { type: 'text' } }>(); const emit = defineEmits<{ - (ev: 'update:modelValue', value: any): void; + (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void; }>(); let autocomplete: Autocomplete; 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 4967e73000..f191320180 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => $emit('update:modelValue', v)"> +<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)"> <template #item="{element}"> <div :class="$style.item"> <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index bac1d2bb70..4cacbd0906 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -52,7 +52,7 @@ const props = defineProps<{ const scope = computed(() => props.path ? props.path.split('/') : []); -const keys = ref<any>(null); +const keys = ref<[string, string][]>([]); function fetchKeys() { misskeyApi('i/registry/keys-with-type', { diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index dfb6e3f53e..437a1a2294 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -132,7 +132,7 @@ const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x. const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; - connection: Misskey.ChannelConnection; + connection: Misskey.ChannelConnection<Misskey.Channels['reversiGame']>; }>(); const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false }); @@ -217,14 +217,14 @@ function onChangeReadyStates(states) { game.value.user2Ready = states.user2; } -function updateSettings(key: keyof Misskey.entities.ReversiGameDetailed) { +function updateSettings(key: typeof Misskey.reversiUpdateKeys[number]) { props.connection.send('updateSettings', { key: key, value: game.value[key], }); } -function onUpdateSettings({ userId, key, value }: { userId: string; key: keyof Misskey.entities.ReversiGameDetailed; value: any; }) { +function onUpdateSettings<K extends typeof Misskey.reversiUpdateKeys[number]>({ userId, key, value }: { userId: string; key: K; value: Misskey.entities.ReversiGameDetailed[K]; }) { if (userId === $i.id) return; if (game.value[key] === value) return; game.value[key] = value; diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 45f8ef21ed..46e510b49b 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template> - <MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200"> + <MkSpacer v-if="error != null" :contentMax="1200"> <div :class="$style.root"> <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> <p :class="$style.text"> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ error }} </p> </div> - </MKSpacer> + </MkSpacer> <MkSpacer v-else-if="tab === 'users'" :contentMax="1200"> <div class="_gaps_s"> <div v-if="role">{{ role.description }}</div> @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkSpacer> <MkSpacer v-else-if="tab === 'timeline'" :contentMax="700"> - <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.role"/> + <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> <div v-else-if="!visible" class="_fullinfo"> <img :src="infoImageUrl" class="_ghost"/> <div>{{ i18n.ts.nothing }}</div> @@ -47,23 +47,24 @@ import { instanceName } from '@@/js/config.js'; import { serverErrorImageUrl, infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ - role: string; + roleId: string; initialTab?: string; }>(), { initialTab: 'users', }); +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const tab = ref(props.initialTab); -const role = ref<Misskey.entities.Role>(); -const error = ref(); +const role = ref<Misskey.entities.Role | null>(null); +const error = ref<string | null>(null); const visible = ref(false); -watch(() => props.role, () => { +watch(() => props.roleId, () => { misskeyApi('roles/show', { - roleId: props.role, + roleId: props.roleId, }).then(res => { role.value = res; - document.title = `${role.value.name} | ${instanceName}`; + error.value = null; visible.value = res.isExplorable && res.isPublic; }).catch((err) => { if (err.code === 'NO_SUCH_ROLE') { @@ -71,7 +72,6 @@ watch(() => props.role, () => { } else { error.value = i18n.ts.somethingHappened; } - document.title = `${error.value} | ${instanceName}`; }); }, { immediate: true }); @@ -79,7 +79,7 @@ const users = computed(() => ({ endpoint: 'roles/users' as const, limit: 30, params: { - roleId: props.role, + roleId: props.roleId, }, })); @@ -94,7 +94,7 @@ const headerTabs = computed(() => [{ }]); definePageMetadata(() => ({ - title: role.value ? role.value.name : i18n.ts.role, + title: role.value ? role.value.name : (error.value ?? i18n.ts.role), icon: 'ti ti-badge', })); </script> diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index 2250e1ce60..88171f7d70 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -76,7 +76,11 @@ import { claimAchievement } from '@/scripts/achievements.js'; const parser = new Parser(); let aiscript: Interpreter; const code = ref(''); -const logs = ref<any[]>([]); +const logs = ref<{ + id: number; + text: string; + print: boolean; +}[]>([]); const root = ref<AsUiRoot>(); const components = ref<Ref<AsUiComponent>[]>([]); const uiKey = ref(0); diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 2244047b31..18c82ffdf6 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -138,12 +138,13 @@ const token = ref<string | number | null>(null); const backupCodes = ref<string[]>(); function cancel() { - dialog.value.close(); + dialog.value?.close(); } async function tokenDone() { + if (token.value == null) return; const res = await os.apiWithDialog('i/2fa/done', { - token: token.value.toString(), + token: typeof token.value === 'string' ? token.value : token.value.toString(), }); backupCodes.value = res.backupCodes; @@ -166,7 +167,7 @@ function downloadBackupCodes() { } function allDone() { - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index a76b748ac1..776f59dda3 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -84,7 +84,7 @@ import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkLink from '@/components/MkLink.vue'; import * as os from '@/os.js'; -import { signinRequired, updateAccount } from '@/account.js'; +import { signinRequired, updateAccountPartial } from '@/account.js'; import { i18n } from '@/i18n.js'; const $i = signinRequired(); @@ -123,7 +123,7 @@ async function unregisterTOTP(): Promise<void> { password: auth.result.password, token: auth.result.token, }).then(res => { - updateAccount({ + updateAccountPartial({ twoFactorEnabled: false, }); }).catch(error => { diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 1bbedb817e..97e960675f 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -19,13 +19,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { ref, computed } from 'vue'; 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 { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -74,27 +74,23 @@ async function removeAccount(account: Misskey.entities.UserDetailed) { } function addExistingAccount() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - await addAccounts(res.id, res.i); + getAccountWithSigninDialog().then((res) => { + if (res != null) { os.success(); init(); - }, - closed: () => dispose(), + } }); } function createAccount() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: async (res: Misskey.entities.SignupResponse) => { - await addAccounts(res.id, res.token); + getAccountWithSignupDialog().then((res) => { + if (res != null) { switchAccountWithToken(res.token); - }, - closed: () => dispose(), + } }); } -async function switchAccount(account: any) { +async function switchAccount(account: Misskey.entities.UserDetailed) { const fetchedAccounts = await getAccounts(); const token = fetchedAccounts.find(x => x.id === account.id)!.token; switchAccountWithToken(token); diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 68e36ef1bb..6515503505 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -55,6 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; import FormPagination from '@/components/MkPagination.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -77,7 +78,7 @@ const pagination = { function revoke(token) { misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => { - list.value.reload(); + list.value?.reload(); }); } diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue index f72a0b9383..3c9914b4e2 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue @@ -10,12 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only > <div :class="$style.name"><MkCondensedLine :minScale="0.5">{{ decoration.name }}</MkCondensedLine></div> <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: decoration.url, angle, flipH, offsetX, offsetY }]" forceShowDecoration/> - <i v-if="decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.lock" class="ti ti-lock"></i> + <i v-if="locked" :class="$style.lock" class="ti ti-lock"></i> </div> </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed } from 'vue'; import { signinRequired } from '@/account.js'; const $i = signinRequired(); @@ -37,6 +37,8 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'click'): void; }>(); + +const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))); </script> <style lang="scss" module> @@ -67,5 +69,6 @@ const emit = defineEmits<{ position: absolute; bottom: 12px; right: 12px; + color: var(--MI_THEME-warn); } </style> diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue index 853e536ea3..40542ad5b2 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.footer" class="_buttonsCenter"> <MkButton v-if="usingIndex != null" primary rounded @click="update"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton> <MkButton v-if="usingIndex != null" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton> - <MkButton v-else :disabled="exceeded" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton> + <MkButton v-else :disabled="exceeded || locked" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton> </div> </div> </MkModalWindow> @@ -61,6 +61,7 @@ const props = defineProps<{ id: string; url: string; name: string; + roleIdsThatCanBeUsedThisDecoration: string[]; }; }>(); @@ -83,6 +84,7 @@ const emit = defineEmits<{ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0); +const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))); const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0); const flipH = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].flipH : null) ?? false); const offsetX = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetX : null) ?? 0); @@ -108,7 +110,7 @@ const decorationsForPreview = computed(() => { }); function cancel() { - dialog.value.close(); + dialog.value?.close(); } async function update() { @@ -118,7 +120,7 @@ async function update() { offsetX: offsetX.value, offsetY: offsetY.value, }); - dialog.value.close(); + dialog.value?.close(); } async function attach() { @@ -128,12 +130,12 @@ async function attach() { offsetX: offsetX.value, offsetY: offsetY.value, }); - dialog.value.close(); + dialog.value?.close(); } async function detach() { emit('detach'); - dialog.value.close(); + dialog.value?.close(); } </script> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index b189db0f8f..c38cdc4fc2 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -100,10 +100,6 @@ function reset() { })); } -watch(menuDisplay, async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue index a36f036303..0ea415f673 100644 --- a/packages/frontend/src/pages/settings/notifications.notification-config.vue +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <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="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option> - <option value="list">{{ i18n.ts.userList }}</option> - <option value="never">{{ i18n.ts.none }}</option> + <option v-for="type in props.configurableTypes ?? notificationConfigTypes" :key="type" :value="type">{{ notificationConfigTypesI18nMap[type] }}</option> </MkSelect> <MkSelect v-if="type === 'list'" v-model="userListId"> @@ -21,31 +15,61 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSelect> <div class="_buttons"> - <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton inline primary :disabled="type === 'list' && userListId === null" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> </template> +<script lang="ts"> +const notificationConfigTypes = [ + 'all', + 'following', + 'follower', + 'mutualFollow', + 'followingOrFollower', + 'list', + 'never' +] as const; + +export type NotificationConfig = { + type: Exclude<typeof notificationConfigTypes[number], 'list'>; +} | { + type: 'list'; + userListId: string; +}; +</script> + <script lang="ts" setup> -import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { ref } from 'vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - value: any; + value: NotificationConfig; userLists: Misskey.entities.UserList[]; + configurableTypes?: NotificationConfig['type'][]; // If not specified, all types are configurable }>(); const emit = defineEmits<{ - (ev: 'update', result: any): void; + (ev: 'update', result: NotificationConfig): void; }>(); +const notificationConfigTypesI18nMap: Record<typeof notificationConfigTypes[number], string> = { + all: i18n.ts.all, + following: i18n.ts.following, + follower: i18n.ts.followers, + mutualFollow: i18n.ts.mutualFollow, + followingOrFollower: i18n.ts.followingOrFollower, + list: i18n.ts.userList, + never: i18n.ts.none, +}; + const type = ref(props.value.type); -const userListId = ref(props.value.userListId); +const userListId = ref(props.value.type === 'list' ? props.value.userListId : null); function save() { - emit('update', { type: type.value, userListId: userListId.value }); + emit('update', type.value === 'list' ? { type: type.value, userListId: userListId.value! } : { type: type.value }); } </script> diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 53b3bd4936..8ffe0d6a7a 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -22,7 +22,12 @@ SPDX-License-Identifier: AGPL-3.0-only }} </template> - <XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" @update="(res) => updateReceiveConfig(type, res)"/> + <XNotificationConfig + :userLists="userLists" + :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" + :configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined" + @update="(res) => updateReceiveConfig(type, res)" + /> </MkFolder> </div> </FormSection> @@ -58,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { shallowRef, computed } from 'vue'; -import XNotificationConfig from './notifications.notification-config.vue'; +import XNotificationConfig, { type NotificationConfig } from './notifications.notification-config.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -73,7 +78,9 @@ import { notificationTypes } from '@@/js/const.js'; const $i = signinRequired(); -const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'achievementEarned', 'test', 'exportCompleted'] as const satisfies (typeof notificationTypes[number])[]; +const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; + +const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login'] satisfies (typeof notificationTypes[number])[] as string[]; const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); @@ -88,7 +95,7 @@ async function readAllNotifications() { await os.apiWithDialog('notifications/mark-all-as-read'); } -async function updateReceiveConfig(type, value) { +async function updateReceiveConfig(type: typeof notificationTypes[number], value: NotificationConfig) { await os.apiWithDialog('i/update', { notificationRecieveConfig: { ...$i.notificationRecieveConfig, diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index d418be624e..40d9be0f60 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.noCrawleDescription }}</template> </MkSwitch> <MkSwitch v-model="preventAiLearning" @update:modelValue="save()"> - {{ i18n.ts.preventAiLearning }}<span class="_beta">{{ i18n.ts.beta }}</span> + {{ i18n.ts.preventAiLearning }} <template #caption>{{ i18n.ts.preventAiLearningDescription }}</template> </MkSwitch> <MkSwitch v-model="isExplorable" @update:modelValue="save()"> @@ -45,6 +45,93 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <FormSection> + <template #label>{{ i18n.ts.lockdown }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + + <div class="_gaps_m"> + <MkSwitch :modelValue="requireSigninToViewContents" @update:modelValue="update_requireSigninToViewContents"> + {{ i18n.ts._accountSettings.requireSigninToViewContents }} + <template #caption> + <div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div> + </template> + </MkSwitch> + + <FormSlot> + <template #label>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</template> + + <div class="_gaps_s"> + <MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> + <option :value="null">{{ i18n.ts.none }}</option> + <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> + <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + </MkSelect> + + <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore"> + <option :value="-3600">{{ i18n.ts.oneHour }}</option> + <option :value="-86400">{{ i18n.ts.oneDay }}</option> + <option :value="-259200">{{ i18n.ts.threeDays }}</option> + <option :value="-604800">{{ i18n.ts.oneWeek }}</option> + <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> + <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> + <option :value="-31104000">{{ i18n.ts.oneYear }}</option> + </MkSelect> + + <MkInput + v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" + :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" + type="date" + :manualSave="true" + @update:modelValue="makeNotesFollowersOnlyBefore = Math.floor(new Date($event).getTime() / 1000)" + > + </MkInput> + </div> + + <template #caption> + <div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> + </template> + </FormSlot> + + <FormSlot> + <template #label>{{ i18n.ts._accountSettings.makeNotesHiddenBefore }}</template> + + <div class="_gaps_s"> + <MkSelect :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> + <option :value="null">{{ i18n.ts.none }}</option> + <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> + <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + </MkSelect> + + <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore"> + <option :value="-3600">{{ i18n.ts.oneHour }}</option> + <option :value="-86400">{{ i18n.ts.oneDay }}</option> + <option :value="-259200">{{ i18n.ts.threeDays }}</option> + <option :value="-604800">{{ i18n.ts.oneWeek }}</option> + <option :value="-2592000">{{ i18n.ts.oneMonth }}</option> + <option :value="-7776000">{{ i18n.ts.threeMonths }}</option> + <option :value="-31104000">{{ i18n.ts.oneYear }}</option> + </MkSelect> + + <MkInput + v-if="makeNotesHiddenBefore_type === 'absolute'" + :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" + type="date" + :manualSave="true" + @update:modelValue="makeNotesHiddenBefore = Math.floor(new Date($event).getTime() / 1000)" + > + </MkInput> + </div> + + <template #caption> + <div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> + </template> + </FormSlot> + </div> + </FormSection> + + <FormSection> <div class="_gaps_m"> <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch> <MkFolder v-if="!rememberNoteVisibility"> @@ -72,7 +159,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; @@ -82,6 +169,10 @@ import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { signinRequired } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import FormSlot from '@/components/form/slot.vue'; +import { formatDateTimeString } from '@/scripts/format-time-string.js'; +import MkInput from '@/components/MkInput.vue'; +import * as os from '@/os.js'; const $i = signinRequired(); @@ -90,6 +181,9 @@ const autoAcceptFollowed = ref($i.autoAcceptFollowed); const noCrawle = ref($i.noCrawle); const preventAiLearning = ref($i.preventAiLearning); const isExplorable = ref($i.isExplorable); +const requireSigninToViewContents = ref($i.requireSigninToViewContents ?? false); +const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null); +const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null); const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); const followingVisibility = ref($i.followingVisibility); @@ -100,6 +194,43 @@ const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNote const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility')); const keepCw = computed(defaultStore.makeGetterSetter('keepCw')); +const makeNotesFollowersOnlyBefore_type = computed(() => { + if (makeNotesFollowersOnlyBefore.value == null) { + return null; + } else if (makeNotesFollowersOnlyBefore.value >= 0) { + return 'absolute'; + } else { + return 'relative'; + } +}); + +const makeNotesHiddenBefore_type = computed(() => { + if (makeNotesHiddenBefore.value == null) { + return null; + } else if (makeNotesHiddenBefore.value >= 0) { + return 'absolute'; + } else { + return 'relative'; + } +}); + +watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => { + save(); +}); + +async function update_requireSigninToViewContents(value: boolean) { + if (value) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.acknowledgeNotesAndEnable, + }); + if (canceled) return; + } + + requireSigninToViewContents.value = value; + save(); +} + function save() { misskeyApi('i/update', { isLocked: !!isLocked.value, @@ -107,6 +238,9 @@ function save() { noCrawle: !!noCrawle.value, preventAiLearning: !!preventAiLearning.value, isExplorable: !!isExplorable.value, + requireSigninToViewContents: !!requireSigninToViewContents.value, + makeNotesFollowersOnlyBefore: makeNotesFollowersOnlyBefore.value, + makeNotesHiddenBefore: makeNotesHiddenBefore.value, hideOnlineStatus: !!hideOnlineStatus.value, publicReactions: !!publicReactions.value, followingVisibility: followingVisibility.value, diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 40d23e36c5..22b008fb61 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton transparent :class="$style.testButton" :disabled="!(active && event_renote)" @click="test('renote')"><i class="ti ti-send"></i></MkButton> </div> <div :class="$style.switchBox"> - <MkSwitch v-model="event_reaction">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch> + <MkSwitch v-model="event_reaction" :disabled="true">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch> <MkButton transparent :class="$style.testButton" :disabled="!(active && event_reaction)" @click="test('reaction')"><i class="ti ti-send"></i></MkButton> </div> <div :class="$style.switchBox"> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index d62357caaf..727c4df2d6 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="event_note">{{ i18n.ts._webhookSettings._events.note }}</MkSwitch> <MkSwitch v-model="event_reply">{{ i18n.ts._webhookSettings._events.reply }}</MkSwitch> <MkSwitch v-model="event_renote">{{ i18n.ts._webhookSettings._events.renote }}</MkSwitch> - <MkSwitch v-model="event_reaction">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch> + <MkSwitch v-model="event_reaction" :disabled="true">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch> <MkSwitch v-model="event_mention">{{ i18n.ts._webhookSettings._events.mention }}</MkSwitch> </div> </FormSection> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 4feba54104..044a1908ab 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -17,11 +17,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.tl"> <MkTimeline ref="tlComponent" - :key="src + withRenotes + withReplies + onlyFiles" + :key="src + withRenotes + withReplies + onlyFiles + withSensitive" :src="src.split(':')[0]" :list="src.split(':')[1]" :withRenotes="withRenotes" :withReplies="withReplies" + :withSensitive="withSensitive" :onlyFiles="onlyFiles" :sound="true" @queue="queueUpdated" @@ -121,11 +122,6 @@ watch(src, () => { queue.value = 0; }); -watch(withSensitive, () => { - // これだけはクライアント側で完結する処理なので手動でリロード - tlComponent.value?.reloadTimeline(); -}); - function queueUpdated(q: number): void { queue.value = q; } diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 00b5740639..2794db2821 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -257,7 +257,7 @@ function parallaxLoop() { } function parallax() { - const banner = bannerEl.value as any; + const banner = bannerEl.value; if (banner == null) return; const top = getScrollPosition(rootEl.value); diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index a6244e2a93..d862387401 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -39,6 +39,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import { getServerContext } from '@/server-context.js'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); @@ -52,6 +53,8 @@ const XFlashs = defineAsyncComponent(() => import('./flashs.vue')); const XGallery = defineAsyncComponent(() => import('./gallery.vue')); const XRaw = defineAsyncComponent(() => import('./raw.vue')); +const CTX_USER = getServerContext('user'); + const props = withDefaults(defineProps<{ acct: string; page?: string; @@ -61,13 +64,24 @@ const props = withDefaults(defineProps<{ const tab = ref(props.page); -const user = ref<null | Misskey.entities.UserDetailed>(null); +const user = ref<null | Misskey.entities.UserDetailed>(CTX_USER); const error = ref<any>(null); function fetchUser(): void { if (props.acct == null) return; + + const { username, host } = Misskey.acct.parse(props.acct); + + if (CTX_USER && CTX_USER.username === username && CTX_USER.host === host) { + user.value = CTX_USER; + return; + } + user.value = null; - misskeyApi('users/show', Misskey.acct.parse(props.acct)).then(u => { + misskeyApi('users/show', { + username, + host, + }).then(u => { user.value = u; }).catch(err => { error.value = err; diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts index ac325e923f..7740fe0d39 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/pizzax.ts @@ -241,9 +241,13 @@ export class Storage<T extends StateDef> { * 特定のキーの、簡易的なgetter/setterを作ります * 主にvue上で設定コントロールのmodelとして使う用 */ - 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; + public makeGetterSetter<K extends keyof T, R = T[K]['default']>( + key: K, + getter?: (v: T[K]['default']) => R, + setter?: (v: R) => T[K]['default'], + ): { + get: () => R; + set: (value: R) => void; } { const valueRef = ref(this.state[key]); @@ -265,7 +269,7 @@ export class Storage<T extends StateDef> { return valueRef.value; } }, - set: (value: unknown) => { + set: (value) => { const val = setter ? setter(value) : value; this.set(key, val); valueRef.value = val; diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index 75f994b865..e98e0b59b1 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -10,7 +10,7 @@ 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({ +export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ loader: loader, loadingComponent: MkLoading, errorComponent: MkError, @@ -217,7 +217,7 @@ const routes: RouteDef[] = [{ component: page(() => import('@/pages/theme-editor.vue')), loginRequired: true, }, { - path: '/roles/:role', + path: '/roles/:roleId', component: page(() => import('@/pages/role.vue')), }, { path: '/user-tags/:tag', diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts index 6ee967e6f4..3c25e80d12 100644 --- a/packages/frontend/src/router/main.ts +++ b/packages/frontend/src/router/main.ts @@ -4,7 +4,7 @@ */ import { EventEmitter } from 'eventemitter3'; -import { IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js'; +import { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js'; import type { App, ShallowRef } from 'vue'; @@ -79,7 +79,7 @@ class MainRouterProxy implements IRouter { return this.supplier().currentRoute; } - get navHook(): ((path: string, flag?: any) => boolean) | null { + get navHook(): ((path: string, flag?: RouterFlag) => boolean) | null { return this.supplier().navHook; } @@ -91,11 +91,11 @@ class MainRouterProxy implements IRouter { return this.supplier().getCurrentKey(); } - getCurrentPath(): any { + getCurrentPath(): string { return this.supplier().getCurrentPath(); } - push(path: string, flag?: any): void { + push(path: string, flag?: RouterFlag): void { this.supplier().push(path, flag); } diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts index 67e896b4b9..0a37a08bf0 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -2,8 +2,9 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import * as Misskey from 'misskey-js'; -export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean { +export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): boolean { // 自分自身 if (me && (note.userId === me.id)) return false; diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts index 7c33f8ccee..7aadb617ca 100644 --- a/packages/frontend/src/scripts/device-kind.ts +++ b/packages/frontend/src/scripts/device-kind.ts @@ -3,22 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defaultStore } from '@/store.js'; - -await defaultStore.ready; +export type DeviceKind = 'smartphone' | 'tablet' | 'desktop'; 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 DEFAULT_DEVICE_KIND: DeviceKind = ( + isSmartphone + ? 'smartphone' + : isTablet + ? 'tablet' + : 'desktop' +); -export const isFullscreenNotSupported = isIPhone || isIos; +export let deviceKind: DeviceKind = DEFAULT_DEVICE_KIND; -export const deviceKind: 'smartphone' | 'tablet' | 'desktop' = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind - : isSmartphone ? 'smartphone' - : isTablet ? 'tablet' - : 'desktop'; +export function updateDeviceKind(kind: DeviceKind | null) { + deviceKind = kind ?? DEFAULT_DEVICE_KIND; +} diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts index 242a504c3b..1032e97ac9 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/scripts/form.ts @@ -15,7 +15,7 @@ type Hidden = boolean | ((v: any) => boolean); export type FormItem = { label?: string; type: 'string'; - default: string | null; + default?: string | null; description?: string; required?: boolean; hidden?: Hidden; @@ -24,7 +24,7 @@ export type FormItem = { } | { label?: string; type: 'number'; - default: number | null; + default?: number | null; description?: string; required?: boolean; hidden?: Hidden; @@ -32,20 +32,20 @@ export type FormItem = { } | { label?: string; type: 'boolean'; - default: boolean | null; + default?: boolean | null; description?: string; hidden?: Hidden; } | { label?: string; type: 'enum'; - default: string | null; + default?: string | null; required?: boolean; hidden?: Hidden; enum: EnumItem[]; } | { label?: string; type: 'radio'; - default: unknown | null; + default?: unknown | null; required?: boolean; hidden?: Hidden; options: { @@ -55,7 +55,7 @@ export type FormItem = { } | { label?: string; type: 'range'; - default: number | null; + default?: number | null; description?: string; required?: boolean; step?: number; @@ -66,12 +66,12 @@ export type FormItem = { } | { label?: string; type: 'object'; - default: Record<string, unknown> | null; + default?: Record<string, unknown> | null; hidden: Hidden; } | { label?: string; type: 'array'; - default: unknown[] | null; + default?: unknown[] | null; hidden: Hidden; } | { type: 'button'; diff --git a/packages/frontend/src/scripts/fullscreen.ts b/packages/frontend/src/scripts/fullscreen.ts new file mode 100644 index 0000000000..7a0a018ef3 --- /dev/null +++ b/packages/frontend/src/scripts/fullscreen.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type PartiallyPartial<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; + +type VideoEl = PartiallyPartial<HTMLVideoElement, 'requestFullscreen'> & { + webkitEnterFullscreen?(): void; + webkitExitFullscreen?(): void; +}; + +type PlayerEl = PartiallyPartial<HTMLElement, 'requestFullscreen'>; + +type RequestFullscreenProps = { + readonly videoEl: VideoEl; + readonly playerEl: PlayerEl; + readonly options?: FullscreenOptions | null; +}; + +type ExitFullscreenProps = { + readonly videoEl: VideoEl; +}; + +export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscreenProps) => { + if (playerEl.requestFullscreen != null) { + playerEl.requestFullscreen(options ?? undefined); + return; + } + if (videoEl.webkitEnterFullscreen != null) { + videoEl.webkitEnterFullscreen(); + return; + } +}; + +export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (document.exitFullscreen != null) { + document.exitFullscreen(); + return; + } + if (videoEl.webkitExitFullscreen != null) { + videoEl.webkitExitFullscreen(); + return; + } +}; diff --git a/packages/frontend/src/scripts/get-bg-color.ts b/packages/frontend/src/scripts/get-bg-color.ts new file mode 100644 index 0000000000..ccf60b454f --- /dev/null +++ b/packages/frontend/src/scripts/get-bg-color.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import tinycolor from 'tinycolor2'; + +export const getBgColor = (elem?: Element | null | undefined): string | null => { + if (elem == null) return null; + + const { backgroundColor: bg } = window.getComputedStyle(elem); + + if (bg && tinycolor(bg).getAlpha() !== 0) { + return bg; + } + + return getBgColor(elem.parentElement); +}; diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index 1b1159fd01..e7a92e2d5c 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -17,7 +17,7 @@ export function misskeyApi< _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, >( endpoint: E, - data: P = {} as any, + data: P & { i?: string | null; } = {} as any, token?: string | null | undefined, signal?: AbortSignal, ): Promise<_ResT> { @@ -30,8 +30,8 @@ export function misskeyApi< 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; + if ($i) data.i = $i.token; + if (token !== undefined) data.i = token; // Send request window.fetch(`${apiUrl}/${endpoint}`, { diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts index 18f05bc7f4..43dcf11936 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/scripts/please-login.ts @@ -44,17 +44,21 @@ export type OpenOnRemoteOptions = { params: Record<string, string>; }; -export function pleaseLogin(path?: string, openOnRemote?: OpenOnRemoteOptions) { +export function pleaseLogin(opts: { + path?: string; + message?: string; + openOnRemote?: OpenOnRemoteOptions; +} = {}) { if ($i) return; const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { autoSet: true, - message: openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired, - openOnRemote, + message: opts.message ?? (opts.openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired), + openOnRemote: opts.openOnRemote, }, { cancelled: () => { - if (path) { - window.location.href = path; + if (opts.path) { + window.location.href = opts.path; } }, closed: () => dispose(), diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index 9aa38178b2..b037aa8acc 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -80,7 +80,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { }); } -function select(src: any, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { +function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { return new Promise((res, rej) => { const keepOriginal = ref(defaultStore.state.keepOriginalUploading); @@ -107,10 +107,10 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Miss }); } -export function selectFile(src: any, label: string | null = null): Promise<Misskey.entities.DriveFile> { +export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { return select(src, label, false).then(files => files[0]); } -export function selectFiles(src: any, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { +export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { return select(src, label, true); } diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts index fed16bc71c..1f6ef1928c 100644 --- a/packages/frontend/src/scripts/shuffle.ts +++ b/packages/frontend/src/scripts/shuffle.ts @@ -6,8 +6,9 @@ /** * 配列をシャッフル (破壊的) */ -export function shuffle<T extends any[]>(array: T): T { - let currentIndex = array.length, randomIndex; +export function shuffle<T extends unknown[]>(array: T): T { + let currentIndex = array.length; + let randomIndex: number; // While there remain elements to shuffle. while (currentIndex !== 0) { diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts index 22dce609c6..713573a377 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/scripts/upload.ts @@ -32,13 +32,13 @@ const mimeTypeMap = { export function uploadFile( file: File, - folder?: any, + folder?: string | Misskey.entities.DriveFolder, name?: string, keepOriginal: boolean = defaultStore.state.keepOriginalUploading, ): Promise<Misskey.entities.DriveFile> { if ($i == null) throw new Error('Not logged in'); - if (folder && typeof folder === 'object') folder = folder.id; + const _folder = typeof folder === 'string' ? folder : folder?.id; if (file.size > instance.maxFileSize) { alert({ @@ -89,11 +89,11 @@ export function uploadFile( } const formData = new FormData(); - formData.append('i', $i.token); + formData.append('i', $i!.token); formData.append('force', 'true'); formData.append('file', resizedImage ?? file); formData.append('name', ctx.name); - if (folder) formData.append('folderId', folder); + if (_folder) formData.append('folderId', _folder); const xhr = new XMLHttpRequest(); xhr.open('POST', apiUrl + '/drive/files/create', true); diff --git a/packages/frontend/src/server-context.ts b/packages/frontend/src/server-context.ts new file mode 100644 index 0000000000..aa44a10290 --- /dev/null +++ b/packages/frontend/src/server-context.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as Misskey from 'misskey-js'; +import { $i } from '@/account.js'; + +const providedContextEl = document.getElementById('misskey_clientCtx'); + +export type ServerContext = { + clip?: Misskey.entities.Clip; + note?: Misskey.entities.Note; + user?: Misskey.entities.UserLite; +} | null; + +export const serverContext: ServerContext = (providedContextEl && providedContextEl.textContent) ? JSON.parse(providedContextEl.textContent) : null; + +export function getServerContext<K extends keyof NonNullable<ServerContext>>(entity: K): Required<Pick<NonNullable<ServerContext>, K>> | null { + // contextは非ログイン状態の情報しかないためログイン時は利用できない + if ($i) return null; + + return serverContext ? (serverContext[entity] ?? null) : null; +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index aab67e0b5c..1d981e897b 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -8,9 +8,11 @@ import * as Misskey from 'misskey-js'; import { hemisphere } from '@@/js/intl-const.js'; import lightTheme from '@@/themes/l-light.json5'; import darkTheme from '@@/themes/d-green-lime.json5'; -import { miLocalStorage } from './local-storage.js'; import type { SoundType } from '@/scripts/sound.js'; +import { DEFAULT_DEVICE_KIND, type DeviceKind } from '@/scripts/device-kind.js'; +import { miLocalStorage } from '@/local-storage.js'; import { Storage } from '@/pizzax.js'; +import type { Ast } from '@syuilo/aiscript'; interface PostFormAction { title: string, @@ -206,7 +208,7 @@ export const defaultStore = markRaw(new Storage('base', { overridedDeviceKind: { where: 'device', - default: null as null | 'smartphone' | 'tablet' | 'desktop', + default: null as DeviceKind | null, }, serverDisconnectedBehavior: { where: 'device', @@ -262,11 +264,11 @@ export const defaultStore = markRaw(new Storage('base', { }, useBlurEffectForModal: { where: 'device', - default: !/mobile|iphone|android/.test(navigator.userAgent.toLowerCase()), // 循環参照するのでdevice-kind.tsは参照できない + default: DEFAULT_DEVICE_KIND === 'desktop', }, useBlurEffect: { where: 'device', - default: !/mobile|iphone|android/.test(navigator.userAgent.toLowerCase()), // 循環参照するのでdevice-kind.tsは参照できない + default: DEFAULT_DEVICE_KIND === 'desktop', }, showFixedPostForm: { where: 'device', @@ -516,7 +518,7 @@ export type Plugin = { token: string; src: string | null; version: string; - ast: any[]; + ast: Ast.Node[]; author?: string; description?: string; permissions?: string[]; @@ -554,13 +556,13 @@ export class ColdDeviceStorage { } public static getAll(): Partial<typeof this.default> { - return (Object.keys(this.default) as (keyof typeof this.default)[]).reduce((acc, key) => { + return (Object.keys(this.default) as (keyof typeof this.default)[]).reduce<Partial<typeof this.default>>((acc, key) => { const value = localStorage.getItem(PREFIX + key); if (value != null) { acc[key] = JSON.parse(value); } return acc; - }, {} as any); + }, {}); } public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { @@ -605,7 +607,7 @@ export class ColdDeviceStorage { get: () => { return valueRef.value; }, - set: (value: unknown) => { + set: (value: typeof ColdDeviceStorage.default[K]) => { const val = value; ColdDeviceStorage.set(key, val); }, diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 4204c5af59..48aacf10bc 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -497,20 +497,17 @@ html[data-color-scheme=dark] ._woodenFrame { 10%, 20% { - transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + transform: scale3d(0.91, 0.91, 0.91) rotate3d(0, 0, 1, -2deg); } 30%, - 50%, - 70%, - 90% { - transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + 70% { + transform: scale3d(1.09, 1.09, 1.09) rotate3d(0, 0, 1, 2deg); } - 40%, - 60%, - 80% { - transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + 50%, + 90% { + transform: scale3d(1.09, 1.09, 1.09) rotate3d(0, 0, 1, -2deg); } to { diff --git a/packages/frontend/src/types/post-form.ts b/packages/frontend/src/types/post-form.ts new file mode 100644 index 0000000000..5bb04a95a0 --- /dev/null +++ b/packages/frontend/src/types/post-form.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +export interface PostFormProps { + reply?: Misskey.entities.Note; + renote?: Misskey.entities.Note; + channel?: Misskey.entities.Channel; // TODO + mention?: 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.UserDetailed[]; + initialNote?: Misskey.entities.Note; + instant?: boolean; +}; diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 8ae11efa2c..8fc76741e3 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -56,6 +56,21 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> </div> + <button v-if="!forceIconOnly" class="_button" :class="$style.toggleButton" @click="toggleIconOnly"> + <!-- + <svg viewBox="0 0 16 48" :class="$style.toggleButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,13.3333)"> + <path d="M23.935,-24C37.223,-24 47.995,-7.842 47.995,12.09C47.995,34.077 47.995,62.07 47.995,84.034C47.995,93.573 45.469,102.721 40.972,109.466C36.475,116.211 30.377,120 24.018,120L23.997,120C10.743,120 -0.003,136.118 -0.003,156C-0.003,156 -0.003,156 -0.003,156L-0.003,-60L-0.003,-59.901C-0.003,-50.379 2.519,-41.248 7.007,-34.515C11.496,-27.782 17.584,-24 23.931,-24C23.932,-24 23.934,-24 23.935,-24Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + --> + <svg viewBox="0 0 16 64" :class="$style.toggleButtonShape"> + <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> + <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> + </g> + </svg> + <i :class="'ti ' + `ti-chevron-${ iconOnly ? 'right' : 'left' }`" style="font-size: 12px; margin-left: -8px;"></i> + </button> </div> </template> @@ -80,9 +95,11 @@ const otherMenuItemIndicated = computed(() => { return false; }); -const calcViewState = () => { - iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon'); -}; +const forceIconOnly = window.innerWidth <= 1279; + +function calcViewState() { + iconOnly.value = forceIconOnly || (defaultStore.state.menuDisplay === 'sideIcon'); +} calcViewState(); @@ -92,6 +109,10 @@ watch(defaultStore.reactiveState.menuDisplay, () => { calcViewState(); }); +function toggleIconOnly() { + defaultStore.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); +} + function openAccountMenu(ev: MouseEvent) { openAccountMenu_({ withExtraOperation: true, @@ -133,6 +154,38 @@ function more(ev: MouseEvent) { contain: strict; display: flex; flex-direction: column; + direction: rtl; // スクロールバーを左に表示したいため +} + +.top { + direction: ltr; +} + +.middle { + direction: ltr; +} + +.bottom { + direction: ltr; +} + +.toggleButton { + position: fixed; + bottom: 20px; + left: var(--nav-width); + z-index: 1001; + width: 16px; + height: 64px; + box-sizing: border-box; +} + +.toggleButtonShape { + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 16px; + height: 64px; } .root:not(.iconOnly) { @@ -363,6 +416,10 @@ function more(ev: MouseEvent) { position: relative; font-size: 0.9em; } + + .toggleButton { + left: var(--nav-width); + } } .root.iconOnly { @@ -563,5 +620,9 @@ function more(ev: MouseEvent) { font-size: 10px; } } + + .toggleButton { + left: var(--nav-icon-only-width); + } } </style> diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 550fc39b00..da8fa8bb21 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -48,7 +48,7 @@ const fetching = ref(true); const key = ref(0); const tick = () => { - window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + window.fetch(`/api/fetch-rss?url=${encodeURIComponent(props.url)}`, {}).then(res => { res.json().then((feed: Misskey.entities.FetchRssResponse) => { if (props.shuffle) { shuffle(feed.items); diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index eb587554b9..3186982349 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -49,6 +49,7 @@ export type Column = { tl?: BasicTimelineType; withRenotes?: boolean; withReplies?: boolean; + withSensitive?: boolean; onlyFiles?: boolean; soundSetting: SoundStore; }; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 01da92f731..74c4fb504b 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :src="column.tl" :withRenotes="withRenotes" :withReplies="withReplies" + :withSensitive="withSensitive" :onlyFiles="onlyFiles" @note="onNote" /> @@ -54,6 +55,7 @@ const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const withRenotes = ref(props.column.withRenotes ?? true); const withReplies = ref(props.column.withReplies ?? false); +const withSensitive = ref(props.column.withSensitive ?? true); const onlyFiles = ref(props.column.onlyFiles ?? false); watch(withRenotes, v => { @@ -68,6 +70,12 @@ watch(withReplies, v => { }); }); +watch(withSensitive, v => { + updateColumn(props.column.id, { + withSensitive: v, + }); +}); + watch(onlyFiles, v => { updateColumn(props.column.id, { onlyFiles: v, @@ -144,6 +152,10 @@ const menu = computed<MenuItem[]>(() => { text: i18n.ts.fileAttachedOnly, ref: onlyFiles, disabled: hasWithReplies(props.column.tl) ? withReplies : false, + }, { + type: 'switch', + text: i18n.ts.withSensitive, + ref: withSensitive, }); return menuItems; diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index 34be8c5e57..40e2d8fbc7 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -68,10 +68,10 @@ const onDriveFileCreated = (file) => { } }; -const thumbnail = (image: any): string => { +const thumbnail = (image: Misskey.entities.DriveFile): string => { return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) - : image.thumbnailUrl; + : image.thumbnailUrl ?? image.url; }; misskeyApi('drive/stream', { diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index 3e43687709..92dc6d148e 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -70,7 +70,7 @@ const items = computed(() => rawItems.value.slice(0, widgetProps.maxEntries)); const fetching = ref(true); const fetchEndpoint = computed(() => { const url = new URL('/api/fetch-rss', base); - url.searchParams.set('url', widgetProps.url); + url.searchParams.set('url', encodeURIComponent(widgetProps.url)); return url; }); const intervalClear = ref<(() => void) | undefined>(); diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 4f594b720f..6957878572 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -99,7 +99,7 @@ const items = computed(() => { const fetching = ref(true); const fetchEndpoint = computed(() => { const url = new URL('/api/fetch-rss', base); - url.searchParams.set('url', widgetProps.url); + url.searchParams.set('url', encodeURIComponent(widgetProps.url)); return url; }); const intervalClear = ref<(() => void) | undefined>(); |