diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2024-06-01 11:27:03 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-06-01 11:27:03 +0900 |
| commit | fce66b85b603caac79e1bfa87b5f4621b1ba9d4e (patch) | |
| tree | d22952ee3f8e30057977a99a33823f4d52990fbc /packages/frontend/src/pages | |
| parent | Merge pull request #13493 from misskey-dev/develop (diff) | |
| parent | fix(backend): use insertOne insteadof insert/findOneOrFail combination (#13908) (diff) | |
| download | misskey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.tar.gz misskey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.tar.bz2 misskey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.zip | |
Merge pull request #13917 from misskey-dev/develop
Release 2024.5.0 (master)
Diffstat (limited to 'packages/frontend/src/pages')
37 files changed, 875 insertions, 304 deletions
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 1a49dbf1d5..b55ae220d8 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -222,6 +222,24 @@ const patronsWithIcon = [{ }, { name: '有栖かずみ', icon: 'https://assets.misskey-hub.net/patrons/9240e8e0ba294a8884143e99ac7ed6a0.jpg', +}, { + name: 'イカロ(コアラ)', + icon: 'https://assets.misskey-hub.net/patrons/50b9bdc03735412c80807dbdf32cecb6.jpg', +}, { + name: 'ハチノス3号', + icon: 'https://assets.misskey-hub.net/patrons/030347a6f8ce4e82bc5184b5aad09a18.jpg', +}, { + name: 'Takeno', + icon: 'https://assets.misskey-hub.net/patrons/6fba81536aea48fe94a30909c502dfa1.jpg', +}, { + name: 'くびすじ', + icon: 'https://assets.misskey-hub.net/patrons/aa5789850b2149aeb5b89ebe2e9083db.jpg', +}, { + name: '古道京紗@ぷらいべったー', + icon: 'https://assets.misskey-hub.net/patrons/18346d0519704963a4beabe6abc170af.jpg', +}, { + name: '越貝鯛丸', + icon: 'https://assets.misskey-hub.net/patrons/86c7374de37849b882d8ebbc833dc968.jpg', }]; const patrons = [ @@ -324,6 +342,8 @@ const patrons = [ 'てば', 'たっくん', 'SHO SEKIGUCHI', + '塩キャベツ', + 'はとぽぷさん', ]; 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 2cef55df6c..f57aa51b5b 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -416,7 +416,7 @@ async function assignRole() { if (canceled) return; const { canceled: canceled2, result: period } = await os.select({ - title: i18n.ts.period, + title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name, items: [{ value: 'indefinitely', text: i18n.ts.indefinitely, }, { diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 2f5b4c47d8..f001a4ac20 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -9,6 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSelect v-model="type" :class="$style.typeSelect"> <option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option> <option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option> + <option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option> + <option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option> + <option value="isBot">{{ i18n.ts._role._condition.isBot }}</option> + <option value="isCat">{{ i18n.ts._role._condition.isCat }}</option> + <option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option> <option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option> <option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option> <option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index de27e1f67a..0aaa398584 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -58,6 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import XHeader from './_header_.vue'; import MkInput from '@/components/MkInput.vue'; @@ -90,8 +91,17 @@ const pagination = { })), }; -function getStatus(instance) { - if (instance.isSuspended) return 'Suspended'; +function getStatus(instance: Misskey.entities.FederationInstance) { + switch (instance.suspensionState) { + case 'manuallySuspended': + return 'Manually Suspended'; + case 'goneSuspended': + return 'Automatically Suspended (Gone)'; + case 'autoSuspendedForNotResponding': + return 'Automatically Suspended (Not Responding)'; + case 'none': + break; + } if (instance.isBlocked) return 'Blocked'; if (instance.isSilenced) return 'Silenced'; if (instance.isNotResponding) return 'Error'; diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 3fe021e771..5132b85c64 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -42,7 +42,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { lookupFile } from '@/scripts/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -73,33 +73,10 @@ function clear() { }); } -function show(file) { - os.pageWindow(`/admin/file/${file.id}`); -} - -async function find() { - const { canceled, result: q } = await os.inputText({ - title: i18n.ts.fileIdOrUrl, - minLength: 1, - }); - if (canceled) return; - - misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { - show(file); - }).catch(err => { - if (err.code === 'NO_SUCH_FILE') { - os.alert({ - type: 'error', - text: i18n.ts.notFound, - }); - } - }); -} - const headerActions = computed(() => [{ text: i18n.ts.lookup, icon: 'ti ti-search', - handler: find, + handler: lookupFile, }, { text: i18n.ts.clearCachedFiles, icon: 'ti ti-trash', diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index d4a41c66cc..794feae202 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -12,10 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only <img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> - <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> - <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <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="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> <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> </div> @@ -33,9 +36,10 @@ import { i18n } from '@/i18n.js'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instance } from '@/instance.js'; +import { lookup } from '@/scripts/lookup.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js'; +import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js'; import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { useRouter } from '@/router/supplier.js'; @@ -60,6 +64,7 @@ const pageProps = ref({}); let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; let noEmailServer = !instance.enableEmail; +let noInquiryUrl = isEmpty(instance.inquiryUrl); const thereIsUnresolvedAbuseReport = ref(false); const currentPage = computed(() => router.currentRef.value.child); @@ -82,7 +87,7 @@ const menuDef = computed(() => [{ type: 'button', icon: 'ti ti-search', text: i18n.ts.lookup, - action: lookup, + action: adminLookup, }, ...(instance.disableRegistration ? [{ type: 'button', icon: 'ti ti-user-plus', @@ -282,7 +287,7 @@ function invite() { }); } -function lookup(ev: MouseEvent) { +function adminLookup(ev: MouseEvent) { os.popupMenu([{ text: i18n.ts.user, icon: 'ti ti-user', @@ -296,22 +301,16 @@ function lookup(ev: MouseEvent) { lookupUserByEmail(); }, }, { - text: i18n.ts.note, - icon: 'ti ti-pencil', - action: () => { - alert('TODO'); - }, - }, { text: i18n.ts.file, icon: 'ti ti-cloud', action: () => { - alert('TODO'); + lookupFile(); }, }, { - text: i18n.ts.instance, - icon: 'ti ti-planet', + text: i18n.ts.lookup, + icon: 'ti ti-world-search', action: () => { - alert('TODO'); + lookup(); }, }], ev.currentTarget ?? ev.target); } @@ -353,10 +352,6 @@ defineExpose({ > .nav { .lxpfedzu { - > .info { - margin: 16px 0; - } - > .banner { margin: 16px; diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 9efb34ac9a..a75799696d 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -30,6 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.privacyPolicyUrl }}</template> </MkInput> + <MkInput v-model="inquiryUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template> + <template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template> + </MkInput> + <MkTextarea v-model="preservedUsernames"> <template #label>{{ i18n.ts.preservedUsernames }}</template> <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> @@ -86,6 +92,7 @@ const hiddenTags = ref<string>(''); const preservedUsernames = ref<string>(''); const tosUrl = ref<string | null>(null); const privacyPolicyUrl = ref<string | null>(null); +const inquiryUrl = ref<string | null>(null); async function init() { const meta = await misskeyApi('admin/meta'); @@ -97,6 +104,7 @@ async function init() { preservedUsernames.value = meta.preservedUsernames.join('\n'); tosUrl.value = meta.tosUrl; privacyPolicyUrl.value = meta.privacyPolicyUrl; + inquiryUrl.value = meta.inquiryUrl; } function save() { @@ -105,6 +113,7 @@ function save() { emailRequiredForSignup: emailRequiredForSignup.value, tosUrl: tosUrl.value, privacyPolicyUrl: privacyPolicyUrl.value, + inquiryUrl: inquiryUrl.value, sensitiveWords: sensitiveWords.value.split('\n'), prohibitedWords: prohibitedWords.value.split('\n'), hiddenTags: hiddenTags.value.split('\n'), diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index ab8005045b..8b3c906d8a 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -119,7 +119,7 @@ async function assign() { const user = await os.selectUser({ includeSelf: true }); const { canceled: canceled2, result: period } = await os.select({ - title: i18n.ts.period, + title: i18n.ts.period + ': ' + role.name, items: [{ value: 'indefinitely', text: i18n.ts.indefinitely, }, { diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index c4745978df..9bccee89a5 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -118,19 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </div> </MkFolder> - - <MkFolder> - <template #label>Summaly Proxy</template> - - <div class="_gaps_m"> - <MkInput v-model="summalyProxy"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>Summaly Proxy URL</template> - </MkInput> - - <MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> </div> </FormSuspense> </MkSpacer> @@ -155,7 +142,6 @@ import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -const summalyProxy = ref<string>(''); const enableHcaptcha = ref<boolean>(false); const enableMcaptcha = ref<boolean>(false); const enableRecaptcha = ref<boolean>(false); @@ -175,7 +161,6 @@ const bannedEmailDomains = ref<string>(''); async function init() { const meta = await misskeyApi('admin/meta'); - summalyProxy.value = meta.summalyProxy; enableHcaptcha.value = meta.enableHcaptcha; enableMcaptcha.value = meta.enableMcaptcha; enableRecaptcha.value = meta.enableRecaptcha; @@ -201,7 +186,6 @@ async function init() { function save() { os.apiWithDialog('admin/update-meta', { - summalyProxy: summalyProxy.value, sensitiveMediaDetection: sensitiveMediaDetection.value, sensitiveMediaDetectionSensitivity: sensitiveMediaDetectionSensitivity.value === 0 ? 'veryLow' : diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 9a198ee8a3..6f45c212ec 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -143,6 +143,53 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </FormSection> + + <FormSection> + <template #label>{{ i18n.ts._urlPreviewSetting.title }}</template> + + <div class="_gaps_m"> + <MkSwitch v-model="urlPreviewEnabled"> + <template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template> + </MkSwitch> + + <MkSwitch v-model="urlPreviewRequireContentLength"> + <template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template> + </MkSwitch> + + <MkInput v-model="urlPreviewMaximumContentLength" type="number"> + <template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template> + </MkInput> + + <MkInput v-model="urlPreviewTimeout" type="number"> + <template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template> + </MkInput> + + <MkInput v-model="urlPreviewUserAgent" type="text"> + <template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template> + <template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template> + </MkInput> + + <div> + <MkInput v-model="urlPreviewSummaryProxyUrl" type="text"> + <template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template> + <template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template> + </MkInput> + + <div :class="$style.subCaption"> + {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }} + <ul style="padding-left: 20px; margin: 4px 0"> + <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li> + <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li> + <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li> + <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li> + </ul> + </div> + </div> + </div> + </FormSection> </div> </FormSuspense> </MkSpacer> @@ -173,6 +220,8 @@ import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; const name = ref<string | null>(null); const shortName = ref<string | null>(null); @@ -194,6 +243,12 @@ const perRemoteUserUserTimelineCacheMax = ref<number>(0); const perUserHomeTimelineCacheMax = ref<number>(0); const perUserListTimelineCacheMax = ref<number>(0); const notesPerOneAd = ref<number>(0); +const urlPreviewEnabled = ref<boolean>(true); +const urlPreviewTimeout = ref<number>(10000); +const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10); +const urlPreviewRequireContentLength = ref<boolean>(true); +const urlPreviewUserAgent = ref<string | null>(null); +const urlPreviewSummaryProxyUrl = ref<string | null>(null); async function init(): Promise<void> { const meta = await misskeyApi('admin/meta'); @@ -217,9 +272,15 @@ async function init(): Promise<void> { perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax; perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax; notesPerOneAd.value = meta.notesPerOneAd; + urlPreviewEnabled.value = meta.urlPreviewEnabled; + urlPreviewTimeout.value = meta.urlPreviewTimeout; + urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength; + urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength; + urlPreviewUserAgent.value = meta.urlPreviewUserAgent; + urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl; } -async function save(): void { +async function save() { await os.apiWithDialog('admin/update-meta', { name: name.value, shortName: shortName.value === '' ? null : shortName.value, @@ -241,6 +302,12 @@ async function save(): void { perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value, perUserListTimelineCacheMax: perUserListTimelineCacheMax.value, notesPerOneAd: notesPerOneAd.value, + urlPreviewEnabled: urlPreviewEnabled.value, + urlPreviewTimeout: urlPreviewTimeout.value, + urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value, + urlPreviewRequireContentLength: urlPreviewRequireContentLength.value, + urlPreviewUserAgent: urlPreviewUserAgent.value, + urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value, }); fetchInstance(true); @@ -259,4 +326,9 @@ definePageMetadata(() => ({ -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); } + +.subCaption { + font-size: 0.85em; + color: var(--fgTransparentWeak); +} </style> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 06317760d2..7d87b97a36 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -63,7 +63,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; -import { lookupUser } from '@/scripts/lookup-user.js'; +import { lookupUser } from '@/scripts/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue new file mode 100644 index 0000000000..85ae9062d4 --- /dev/null +++ b/packages/frontend/src/pages/announcement.vue @@ -0,0 +1,142 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :contentMax="800"> + <Transition + :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + mode="out-in" + > + <div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement"> + <div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div> + <div :class="$style.header"> + <span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> + <span style="margin-right: 0.5em;"> + <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> + <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> + <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> + <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> + </span> + <Mfm :text="announcement.title"/> + </div> + <div :class="$style.content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> + </div> + <div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/> + </div> + </div> + <div v-if="$i && !announcement.silence && !announcement.isRead" :class="$style.footer"> + <MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> + </div> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </Transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { $i, updateAccount } from '@/account.js'; +import { defaultStore } from '@/store.js'; + +const props = defineProps<{ + announcementId: string; +}>(); + +const announcement = ref<Misskey.entities.Announcement | null>(null); +const error = ref<any>(null); +const path = computed(() => props.announcementId); + +function fetch() { + announcement.value = null; + misskeyApi('announcements/show', { + announcementId: props.announcementId, + }).then(async _announcement => { + announcement.value = _announcement; + }).catch(err => { + error.value = err; + }); +} + +async function read(target: Misskey.entities.Announcement): Promise<void> { + if (target.needConfirmationToRead) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts._announcement.readConfirmTitle, + text: i18n.tsx._announcement.readConfirmText({ title: target.title }), + }); + if (confirm.canceled) return; + } + + target.isRead = true; + await misskeyApi('i/read-announcement', { announcementId: target.id }); + if ($i) { + updateAccount({ + unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), + }); + } +} + +watch(() => path.value, fetch, { immediate: true }); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePageMetadata(() => ({ + title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements, + icon: 'ti ti-speakerphone', +})); +</script> + +<style lang="scss" module> +.announcement { + padding: 16px; +} + +.forYou { + display: flex; + align-items: center; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; +} + +.header { + margin-bottom: 16px; + font-weight: bold; + font-size: 120%; +} + +.content { + > img { + display: block; + max-height: 300px; + max-width: 100%; + } +} + +.footer { + margin-top: 16px; +} +</style> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index bcd6eb7c0f..e50b208775 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> </span> - <span>{{ announcement.title }}</span> + <MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA> </div> <div :class="$style.content"> <Mfm :text="announcement.text"/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> - <div style="opacity: 0.7; font-size: 85%;"> - <MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> - </div> + <MkA :to="`/announcements/${announcement.id}`"> + <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> + </div> + <div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;"> + {{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/> + </div> + </MkA> </div> <div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> <MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> @@ -73,24 +78,24 @@ const paginationEl = ref<InstanceType<typeof MkPagination>>(); const tab = ref('current'); -async function read(announcement) { - if (announcement.needConfirmationToRead) { +async function read(target) { + if (target.needConfirmationToRead) { const confirm = await os.confirm({ type: 'question', title: i18n.ts._announcement.readConfirmTitle, - text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }), + text: i18n.tsx._announcement.readConfirmText({ title: target.title }), }); if (confirm.canceled) return; } if (!paginationEl.value) return; - paginationEl.value.updateItem(announcement.id, a => { + paginationEl.value.updateItem(target.id, a => { a.isRead = true; return a; }); - misskeyApi('i/read-announcement', { announcementId: announcement.id }); + misskeyApi('i/read-announcement', { announcementId: target.id }); updateAccount({ - unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), + unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), }); } diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 611ae6feca..a895df76e8 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -83,6 +83,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { deviceKind } from '@/scripts/device-kind.js'; import MkNotes from '@/components/MkNotes.vue'; import { url } from '@/config.js'; +import { favoritedChannelsCache } from '@/cache.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import { defaultStore } from '@/store.js'; @@ -153,6 +154,7 @@ function favorite() { channelId: channel.value.id, }).then(() => { favorited.value = true; + favoritedChannelsCache.delete(); }); } @@ -168,6 +170,7 @@ async function unfavorite() { channelId: channel.value.id, }).then(() => { favorited.value = false; + favoritedChannelsCache.delete(); }); } diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index c38cc117bc..fd64a55c65 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -9,11 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :contentMax="800"> <div v-if="clip" class="_gaps"> <div class="_panel"> - <div v-if="clip.description" :class="$style.description"> - <Mfm :text="clip.description" :isNote="false"/> + <div class="_gaps_s" :class="$style.description"> + <div v-if="clip.description"> + <Mfm :text="clip.description" :isNote="false"/> + </div> + <div v-else>({{ i18n.ts.noDescription }})</div> + <div> + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> + </div> </div> - <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> <div :class="$style.user"> <MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> </div> diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue new file mode 100644 index 0000000000..bcdcf43275 --- /dev/null +++ b/packages/frontend/src/pages/contact.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :contentMax="600" :marginMin="20"> + <div class="_gaps"> + <MkKeyValue> + <template #key>{{ i18n.ts.inquiry }}</template> + <template #value> + <MkLink :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink> + </template> + </MkKeyValue> + + <MkKeyValue> + <template #key>{{ i18n.ts.email }}</template> + <template #value> + <div>{{ instance.maintainerEmail }}</div> + </template> + </MkKeyValue> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { instance } from '@/instance.js'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkLink from '@/components/MkLink.vue'; + +definePageMetadata(() => ({ + title: i18n.ts.inquiry, + icon: 'ti ti-help-circle', +})); +</script> diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index b5c8e70166..cfdb235d3a 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -29,6 +29,9 @@ const paginationForPolls = { endpoint: 'notes/polls/recommendation' as const, limit: 10, offsetMode: true, + params: { + excludeChannels: true, + }, }; const tab = ref('notes'); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 4418172e62..3445da26a2 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCodeEditor v-model="script" lang="is"> <template #label>{{ i18n.ts._play.script }}</template> </MkCodeEditor> - <div class="_buttons"> - <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> - <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton> - <MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> - </div> <MkSelect v-model="visibility"> <template #label>{{ i18n.ts.visibility }}</template> + <template #caption>{{ i18n.ts._play.visibilityDescription }}</template> <option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option> <option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option> </MkSelect> + <div class="_buttons"> + <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton> + <MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> </div> </MkSpacer> </MkStickyContainer> @@ -47,7 +48,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import { useRouter } from '@/router/supplier.js'; -const PRESET_DEFAULT = `/// @ 0.16.0 +const PRESET_DEFAULT = `/// @ 0.18.0 var name = "" @@ -59,13 +60,13 @@ Ui:render([ Ui:C:button({ text: "Hello" onClick: @() { - Mk:dialog(null \`Hello, {name}!\`) + Mk:dialog(null, \`Hello, {name}!\`) } }) ]) `; -const PRESET_OMIKUJI = `/// @ 0.16.0 +const PRESET_OMIKUJI = `/// @ 0.18.0 // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -80,11 +81,11 @@ let choices = [ "大凶" ] -// シードが「ユーザーID+今日の日付」である乱数生成器を用意 -let random = Math:gen_rng(\`{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`) +// シードが「PlayID+ユーザーID+今日の日付」である乱数生成器を用意 +let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`) // ランダムに選択肢を選ぶ -let chosen = choices[random(0 (choices.len - 1))] +let chosen = choices[random(0, (choices.len - 1))] // 結果のテキスト let result = \`今日のあなたの運勢は **{chosen}** です。\` @@ -108,7 +109,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.16.0 +const PRESET_SHUFFLE = `/// @ 0.18.0 // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -122,13 +123,13 @@ var cursor = 0 @do() { if (cursor != 0) { - results = results.slice(0 (cursor + 1)) + results = results.slice(0, (cursor + 1)) cursor = 0 } let chars = [] for (let i, length) { - let r = Math:rnd(0 (length - 1)) + let r = Math:rnd(0, (length - 1)) chars.push(string.pick(r)) } let result = chars.join("") @@ -162,11 +163,11 @@ var cursor = 0 text: "←" disabled: !(results.len > 1 && (results.len - cursor) > 1) onClick: back - } { + }, { text: "→" disabled: !(results.len > 1 && cursor > 0) onClick: forward - } { + }, { text: "引き直す" onClick: do }] @@ -187,27 +188,27 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.16.0 +const PRESET_QUIZ = `/// @ 0.18.0 let title = '地理クイズ' let qas = [{ q: 'オーストラリアの首都は?' - choices: ['シドニー' 'キャンベラ' 'メルボルン'] + choices: ['シドニー', 'キャンベラ', 'メルボルン'] a: 'キャンベラ' aDescription: '最大の都市はシドニーですが首都はキャンベラです。' -} { +}, { q: '国土面積2番目の国は?' - choices: ['カナダ' 'アメリカ' '中国'] + choices: ['カナダ', 'アメリカ', '中国'] a: 'カナダ' aDescription: '大きい順にロシア、カナダ、アメリカ、中国です。' -} { +}, { q: '二重内陸国ではないのは?' - choices: ['リヒテンシュタイン' 'ウズベキスタン' 'レソト'] + choices: ['リヒテンシュタイン', 'ウズベキスタン', 'レソト'] a: 'レソト' aDescription: 'レソトは(一重)内陸国です。' -} { +}, { q: '閘門がない運河は?' - choices: ['キール運河' 'スエズ運河' 'パナマ運河'] + choices: ['キール運河', 'スエズ運河', 'パナマ運河'] a: 'スエズ運河' aDescription: 'スエズ運河は高低差がないので閘門はありません。' }] @@ -243,9 +244,9 @@ each (let qa, qas) { }) Ui:C:container({ children: [] - } \`{qa.id}:a\`) + }, \`{qa.id}:a\`) ] - } qa.id)) + }, qa.id)) } @finish() { @@ -295,12 +296,12 @@ qaEls.push(Ui:C:container({ onClick: finish }) ] -} 'footer')) +}, 'footer')) Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.16.0 +const PRESET_TIMELINE = `/// @ 0.18.0 // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { @@ -314,7 +315,7 @@ const PRESET_TIMELINE = `/// @ 0.16.0 ]) // タイムライン取得 - let notes = Mk:api("notes/local-timeline" {}) + let notes = Mk:api("notes/local-timeline", {}) // それぞれのノートごとにUI要素作成 let noteEls = [] @@ -367,7 +368,7 @@ const props = defineProps<{ }>(); const flash = ref<Misskey.entities.Flash | null>(null); -const visibility = ref<Misskey.entities.FlashUpdateRequest['visibility']>('public'); +const visibility = ref<'private' | 'public'>('public'); if (props.id) { flash.value = await misskeyApi('flash/show', { @@ -420,6 +421,7 @@ async function save() { summary: summary.value, permissions: permissions.value, script: script.value, + visibility: visibility.value, }); router.push('/play/' + created.id + '/edit'); } diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 4aa3ce1672..40499fde0e 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -15,11 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAsUi v-if="root" :component="root" :components="components"/> </div> <div class="actions _panel"> - <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> - <MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton> - <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton> - <MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> + <div class="items"> + <MkButton v-tooltip="i18n.ts.reload" class="button" rounded @click="reset"><i class="ti ti-reload"></i></MkButton> + </div> + <div class="items"> + <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> + <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton> + <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> + </div> </div> </div> <div v-else :class="$style.ready"> @@ -49,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--accent);">{{ i18n.ts._play.editThisPage }}</MkA> <MkAd :prefer="['horizontal', 'horizontal-big']"/> </div> - <MkError v-else-if="error" @retry="fetchPage()"/> + <MkError v-else-if="error" @retry="fetchFlash()"/> <MkLoading v-else/> </Transition> </MkSpacer> @@ -94,12 +98,33 @@ function fetchFlash() { }); } +function share(ev: MouseEvent) { + if (!flash.value) return; + + os.popupMenu([ + { + text: i18n.ts.shareWithNote, + icon: 'ti ti-pencil', + action: shareWithNote, + }, + ...(isSupportShare() ? [{ + text: i18n.ts.share, + icon: 'ti ti-share', + action: shareWithNavigator, + }] : []), + ], ev.currentTarget ?? ev.target); +} + function copyLink() { + if (!flash.value) return; + copyToClipboard(`${url}/play/${flash.value.id}`); os.success(); } -function share() { +function shareWithNavigator() { + if (!flash.value) return; + navigator.share({ title: flash.value.title, text: flash.value.summary, @@ -108,21 +133,28 @@ function share() { } function shareWithNote() { + if (!flash.value) return; + os.post({ - initialText: `${flash.value.title} ${url}/play/${flash.value.id}`, + initialText: `${flash.value.title}\n${url}/play/${flash.value.id}`, + instant: true, }); } function like() { + if (!flash.value) return; + os.apiWithDialog('flash/like', { flashId: flash.value.id, }).then(() => { - flash.value.isLiked = true; - flash.value.likedCount++; + flash.value!.isLiked = true; + flash.value!.likedCount++; }); } async function unlike() { + if (!flash.value) return; + const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unlikeConfirm, @@ -131,8 +163,8 @@ async function unlike() { os.apiWithDialog('flash/unlike', { flashId: flash.value.id, }).then(() => { - flash.value.isLiked = false; - flash.value.likedCount--; + flash.value!.isLiked = false; + flash.value!.likedCount--; }); } @@ -152,6 +184,7 @@ function start() { async function run() { if (aiscript.value) aiscript.value.abort(); + if (!flash.value) return; aiscript.value = new Interpreter({ ...createAiScriptEnv({ @@ -193,12 +226,17 @@ async function run() { } } -onDeactivated(() => { +function reset() { if (aiscript.value) aiscript.value.abort(); + started.value = false; +} + +onDeactivated(() => { + reset(); }); onUnmounted(() => { - if (aiscript.value) aiscript.value.abort(); + reset(); }); const headerActions = computed(() => []); @@ -265,11 +303,19 @@ definePageMetadata(() => ({ } > .actions { - display: flex; - justify-content: center; - gap: 12px; margin-top: 16px; - padding: 16px; + + > .items { + display: flex; + justify-content: center; + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--divider); + + &:last-child { + border-bottom: none; + } + } } } } diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index cb7fe2866c..26797ba85e 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -35,7 +35,16 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSection v-if="iAmModerator"> <template #label>Moderation</template> <div class="_gaps_s"> - <MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch> + <MkKeyValue> + <template #key> + {{ i18n.ts._delivery.status }} + </template> + <template #value> + {{ i18n.ts._delivery._type[suspensionState] }} + </template> + </MkKeyValue> + <MkButton v-if="suspensionState === 'none'" :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton> + <MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton> <MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> @@ -155,7 +164,7 @@ const tab = ref('overview'); const chartSrc = ref('instance-requests'); const meta = ref<Misskey.entities.AdminMetaResponse | null>(null); const instance = ref<Misskey.entities.FederationInstance | null>(null); -const suspended = ref(false); +const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none'); const isBlocked = ref(false); const isSilenced = ref(false); const faviconUrl = ref<string | null>(null); @@ -183,7 +192,7 @@ async function fetch(): Promise<void> { instance.value = await misskeyApi('federation/show-instance', { host: props.host, }); - suspended.value = instance.value?.isSuspended ?? false; + suspensionState.value = instance.value?.suspensionState ?? 'none'; isBlocked.value = instance.value?.isBlocked ?? false; isSilenced.value = instance.value?.isSilenced ?? false; faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview'); @@ -209,11 +218,21 @@ async function toggleSilenced(): Promise<void> { }); } -async function toggleSuspend(): Promise<void> { +async function stopDelivery(): Promise<void> { if (!instance.value) throw new Error('No instance?'); + suspensionState.value = 'manuallySuspended'; await misskeyApi('admin/federation/update-instance', { host: instance.value.host, - isSuspended: suspended.value, + isSuspended: true, + }); +} + +async function resumeDelivery(): Promise<void> { + if (!instance.value) throw new Error('No instance?'); + suspensionState.value = 'none'; + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + isSuspended: false, }); } diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 8b3b3cfbfd..2d026d2fa9 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -26,6 +26,7 @@ const draft = ref({ users: [], keywords: [], excludeKeywords: [], + excludeBots: false, withReplies: false, caseSensitive: false, localOnly: false, diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue index c6dcbadd9b..2949bfc02c 100644 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.users }}</template> <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> </MkTextarea> + <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch> <MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch> <MkTextarea v-model="keywords"> <template #label>{{ i18n.ts.antennaKeywords }}</template> @@ -38,7 +39,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> - <MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch> </div> <div :class="$style.actions"> <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> @@ -78,9 +78,9 @@ const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join(' const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); const caseSensitive = ref<boolean>(props.antenna.caseSensitive); const localOnly = ref<boolean>(props.antenna.localOnly); +const excludeBots = ref<boolean>(props.antenna.excludeBots); const withReplies = ref<boolean>(props.antenna.withReplies); const withFile = ref<boolean>(props.antenna.withFile); -const notify = ref<boolean>(props.antenna.notify); const userLists = ref<Misskey.entities.UserList[] | null>(null); watch(() => src.value, async () => { @@ -94,9 +94,9 @@ async function saveAntenna() { name: name.value, src: src.value, userListId: userListId.value, + excludeBots: excludeBots.value, withReplies: withReplies.value, withFile: withFile.value, - notify: notify.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, users: users.value.trim().split('\n').map(x => x.trim()), diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 803b28899a..1a0d7177fc 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -11,16 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="tab === 'my'" key="my" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps"> - <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> + <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps"> + <MkClipPreview v-for="item in items" :key="item.id" :clip="item"/> </MkPagination> </div> <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> - <MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> + <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/> </div> </MkHorizontalSwipe> </MkSpacer> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 4c985b96e6..97f32d35cd 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -21,14 +21,12 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="_margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note"/> + <MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/> </div> <div v-if="clips && clips.length > 0" class="_margin"> <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> <div class="_gaps"> - <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`"> - <MkClipPreview :clip="item"/> - </MkA> + <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/> </div> </div> <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> @@ -66,6 +64,7 @@ import { defaultStore } from '@/store.js'; const props = defineProps<{ noteId: string; + initialTab?: string; }>(); const note = ref<null | Misskey.entities.Note>(); 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 194a276f89..0a28386986 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 @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XContainer :draggable="true" @remove="() => $emit('remove')"> <template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template> - <section style="padding: 0 16px 0 16px;"> + <section style="padding: 16px;" class="_gaps_s"> <MkInput v-model="id"> <template #label>{{ i18n.ts._pages.blocks._note.id }}</template> <template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index bece32fc11..e73d032000 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -6,48 +6,80 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="page" :key="page.id" class="xcukqgmh"> - <div class="main"> - <!-- - <div class="header"> - <h1>{{ page.title }}</h1> - </div> - --> - <div class="banner"> - <MkMediaImage - v-if="page.eyeCatchingImageId" - :image="page.eyeCatchingImage" - :cover="true" - :disableImageLink="true" - class="thumbnail" - /> + <MkSpacer :contentMax="800"> + <Transition + :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + mode="out-in" + > + <div v-if="page" :key="page.id" class="_gaps"> + <div :class="$style.pageMain"> + <div :class="$style.pageBanner"> + <div :class="$style.pageBannerBgRoot"> + <MkImgWithBlurhash + v-if="page.eyeCatchingImageId" + :class="$style.pageBannerBg" + :hash="page.eyeCatchingImage?.blurhash" + :cover="true" + :forceBlurhash="true" + /> + <img + v-else-if="instance.backgroundImageUrl || instance.bannerUrl" + :class="[$style.pageBannerBg, $style.pageBannerBgFallback1]" + :src="getStaticImageUrl(instance.backgroundImageUrl ?? instance.bannerUrl!)" + /> + <div v-else :class="[$style.pageBannerBg, $style.pageBannerBgFallback2]"></div> + </div> + <div v-if="page.eyeCatchingImageId" :class="$style.pageBannerImage"> + <MkMediaImage + :image="page.eyeCatchingImage!" + :cover="true" + :disableImageLink="true" + :class="$style.thumbnail" + /> + </div> + <div :class="$style.pageBannerTitle" class="_gaps_s"> + <h1>{{ page.title || page.name }}</h1> + <div :class="$style.pageBannerTitleSub"> + <div v-if="page.user" :class="$style.pageBannerTitleUser"> + <MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA> + </div> + <div :class="$style.pageBannerTitleSubActions"> + <MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA> + <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button> + </div> + </div> + </div> </div> - <div class="content"> + <div :class="$style.pageContent"> <XPage :page="page"/> </div> - <div class="actions"> - <div class="like"> + <div :class="$style.pageActions"> + <div> <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> </div> - <div class="other"> - <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> - <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> - <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> + <div :class="$style.other"> + <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> + <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button> </div> </div> - <div class="user"> - <MkAvatar :user="page.user" class="avatar" link preview/> - <div class="name"> - <MkUserName :user="page.user" style="display: block;"/> - <MkAcct :user="page.user"/> - </div> - <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + <div :class="$style.pageUser"> + <MkAvatar :user="page.user" :class="$style.avatar" link preview/> + <MkA :to="`/@${username}`"> + <MkUserName :user="page.user" :class="$style.name"/> + <MkAcct :user="page.user" :class="$style.acct"/> + </MkA> + <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user!" :inline="true" :transparent="false" :full="true" :class="$style.follow"/> </div> - <div class="links"> - <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> + <div :class="$style.pageDate"> + <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> + </div> + <div :class="$style.pageLinks"> + <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> <template v-if="$i && $i.id === page.userId"> <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> @@ -55,10 +87,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> </div> - <div class="footer"> - <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> - <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> - </div> <MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> <template #icon><i class="ti ti-clock"></i></template> @@ -84,6 +112,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { url } from '@/config.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; +import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -94,6 +123,8 @@ import { pageViewInterruptors, defaultStore } from '@/store.js'; import { deepClone } from '@/scripts/clone.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; +import { instance } from '@/instance.js'; +import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ @@ -133,35 +164,63 @@ function fetchPage() { }); } -function share() { - navigator.share({ - title: page.value.title ?? page.value.name, - text: page.value.summary, - url: `${url}/@${page.value.user.username}/pages/${page.value.name}`, - }); +function share(ev: MouseEvent) { + if (!page.value) return; + + os.popupMenu([ + { + text: i18n.ts.shareWithNote, + icon: 'ti ti-pencil', + action: shareWithNote, + }, + ...(isSupportShare() ? [{ + text: i18n.ts.share, + icon: 'ti ti-share', + action: shareWithNavigator, + }] : []), + ], ev.currentTarget ?? ev.target); } function copyLink() { + if (!page.value) return; + copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`); os.success(); } function shareWithNote() { + if (!page.value) return; + os.post({ - initialText: `${page.value.title || page.value.name} ${url}/@${page.value.user.username}/pages/${page.value.name}`, + initialText: `${page.value.title || page.value.name}\n${url}/@${page.value.user.username}/pages/${page.value.name}`, + instant: true, + }); +} + +function shareWithNavigator() { + if (!page.value) return; + + navigator.share({ + title: page.value.title ?? page.value.name, + text: page.value.summary ?? undefined, + url: `${url}/@${page.value.user.username}/pages/${page.value.name}`, }); } function like() { + if (!page.value) return; + os.apiWithDialog('pages/like', { pageId: page.value.id, }).then(() => { - page.value.isLiked = true; - page.value.likedCount++; + page.value!.isLiked = true; + page.value!.likedCount++; }); } async function unlike() { + if (!page.value) return; + const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unlikeConfirm, @@ -170,12 +229,14 @@ async function unlike() { os.apiWithDialog('pages/unlike', { pageId: page.value.id, }).then(() => { - page.value.isLiked = false; - page.value.likedCount--; + page.value!.isLiked = false; + page.value!.likedCount--; }); } function pin(pin) { + if (!page.value) return; + os.apiWithDialog('i/update', { pinnedPageId: pin ? page.value.id : null, }); @@ -200,109 +261,200 @@ definePageMetadata(() => ({ })); </script> -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { +<style lang="scss" module> +.fadeEnterActive, +.fadeLeaveActive { transition: opacity 0.125s ease; } -.fade-enter-from, -.fade-leave-to { +.fadeEnterFrom, +.fadeLeaveTo { opacity: 0; } -.xcukqgmh { - > .main { - padding: 32px; +.generalActionButton { + height: 2.5rem; + width: 2.5rem; + text-align: center; + border-radius: 99rem; - > .header { - padding: 16px; + & :global(.ti) { + line-height: 2.5rem; + } - > h1 { - margin: 0; - } + &:hover, + &:focus-visible { + background-color: var(--accentedBg); + color: var(--accent); + text-decoration: none; + } +} + +.pageMain { + border-radius: var(--radius); + padding: 2rem; + background: var(--panel); + box-sizing: border-box; +} + +.pageBanner { + width: calc(100% + 4rem); + margin: -2rem -2rem 1.5rem; + border-radius: var(--radius) var(--radius) 0 0; + overflow: hidden; + position: relative; + + > .pageBannerBgRoot { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + + .pageBannerBg { + width: 100%; + height: 100%; + object-fit: cover; + opacity: .2; + filter: brightness(1.2); } - > .banner { - > .thumbnail { - // TODO: 良い感じのアスペクト比で表示 - display: block; - width: 100%; - height: auto; - aspect-ratio: 3/1; - border-radius: var(--radius); - overflow: hidden; - object-fit: cover; - } + .pageBannerBgFallback1 { + filter: blur(20px); } - > .content { - margin-top: 16px; - padding: 16px 0 0 0; + .pageBannerBgFallback2 { + background-color: var(--accentedBg); } - > .actions { - display: flex; - align-items: center; - margin-top: 16px; - padding: 16px 0 0 0; - border-top: solid 0.5px var(--divider); + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 100px; + background: linear-gradient(0deg, var(--panel), transparent); + } + } - > .other { - margin-left: auto; + > .pageBannerImage { + position: relative; + padding-top: 56.25%; - > button { - padding: 8px; - margin: 0 8px; + > .thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } - &:hover { - color: var(--fgHighlighted); - } - } - } + > .pageBannerTitle { + position: relative; + padding: 1.5rem 2rem; + + h1 { + font-size: 2rem; + font-weight: 700; + color: var(--fg); + margin: 0; } - > .user { - margin-top: 16px; - padding: 16px 0 0 0; - border-top: solid 0.5px var(--divider); + .pageBannerTitleSub { display: flex; align-items: center; + width: 100%; + } - > .avatar { - width: 52px; - height: 52px; - } + .pageBannerTitleUser { + --height: 32px; + flex-shrink: 0; - > .name { - margin: 0 0 0 12px; - font-size: 90%; + .avatar { + height: var(--height); + width: var(--height); } - > .koudoku { - margin-left: auto; - } + line-height: var(--height); } - > .links { - margin-top: 16px; - padding: 24px 0 0 0; - border-top: solid 0.5px var(--divider); - - > .link { - margin-right: 0.75em; - } + .pageBannerTitleSubActions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--marginHalf); + margin-left: auto; } } +} + +.pageContent { + margin-bottom: 1.5rem; +} + +.pageActions { + display: flex; + align-items: center; + + border-top: 1px solid var(--divider); + padding-top: 1.5rem; + margin-bottom: 1.5rem; + + > .other { + margin-left: auto; + display: flex; + gap: var(--marginHalf); + } +} + +.pageUser { + display: flex; + align-items: center; + + border-top: 1px solid var(--divider); + padding-top: 1.5rem; + margin-bottom: 1.5rem; + + .avatar, + .name, + .acct { + display: block; + } + + .avatar { + width: 4rem; + height: 4rem; + margin-right: 1rem; + } - > .footer { - margin: var(--margin) 0 var(--margin) 0; - font-size: 85%; - opacity: 0.75; + .name { + font-size: 110%; + font-weight: 700; + } + + .acct { + font-size: 90%; + opacity: 0.7; + } + + .follow { + margin-left: auto; } } -</style> -<style module> +.pageDate { + margin-bottom: 1.5rem; +} + +.pageLinks { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--marginHalf); +} + .relatedPagesRoot { padding: var(--margin); } diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 5259dfa29a..175ea62411 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -151,6 +151,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import { deepClone } from '@/scripts/clone.js'; import { useInterval } from '@/scripts/use-interval.js'; import { signinRequired } from '@/account.js'; +import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { userPage } from '@/filters/user.js'; @@ -442,7 +443,7 @@ function autoplay() { function share() { os.post({ - initialText: `#MisskeyReversi ${location.href}`, + initialText: `#MisskeyReversi\n${url}/reversi/g/${game.value.id}`, instant: true, }); } diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 2608560cc4..2244047b31 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -25,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="height: 100cqh; overflow: auto; text-align: center;"> <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps"> + <MkInfo><MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank">{{ i18n.ts._2fa.moreDetailedGuideHere }}</MkLink></MkInfo> + <I18n :src="i18n.ts._2fa.step1" tag="div"> <template #a> <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> @@ -33,8 +35,12 @@ SPDX-License-Identifier: AGPL-3.0-only <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> </template> </I18n> - <div>{{ i18n.ts._2fa.step2 }}<br>{{ i18n.ts._2fa.step2Click }}</div> - <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a> + <div>{{ i18n.ts._2fa.step2 }}</div> + <div> + <a :class="$style.qrRoot" :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a> + <!-- QRコード側にマージンが入っているので直下でOK --> + <div><MkButton inline rounded link :to="twoFactorData.url" :linkBehavior="'browser'">{{ i18n.ts.launchApp }}</MkButton></div> + </div> <MkKeyValue :copy="twoFactorData.url"> <template #key>{{ i18n.ts._2fa.step2Uri }}</template> <template #value>{{ twoFactorData.url }}</template> @@ -52,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :marginMin="20" :marginMax="28"> <div class="_gaps"> <div>{{ i18n.ts._2fa.step3Title }}</div> - <MkInput v-model="token" autocomplete="one-time-code"></MkInput> + <MkInput v-model="token" autocomplete="one-time-code" inputmode="numeric"></MkInput> <div>{{ i18n.ts._2fa.step3 }}</div> </div> <div class="_buttonsCenter" style="margin-top: 16px;"> @@ -109,6 +115,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkLink from '@/components/MkLink.vue'; import { confetti } from '@/scripts/confetti.js'; import { signinRequired } from '@/account.js'; @@ -177,8 +184,14 @@ function allDone() { transform: translateX(-50px); } -.qr { +.qrRoot { + display: block; + margin: 0 auto; width: 200px; max-width: 100%; } + +.qr { + width: 100%; +} </style> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index d8c5f848fe..b7d648c1a4 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -30,7 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> </div> - <MkButton v-else-if="!$i.twoFactorEnabled" primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + <div v-else-if="!$i.twoFactorEnabled" class="_gaps_s"> + <MkButton primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + <MkLink url="https://misskey-hub.net/docs/for-users/stepped-guides/how-to-enable-2fa/" target="_blank"><i class="ti ti-help-circle"></i> {{ i18n.ts.learnMore }}</MkLink> + </div> </MkFolder> <MkFolder> @@ -79,8 +82,9 @@ import MkInfo from '@/components/MkInfo.vue'; import MkSwitch from '@/components/MkSwitch.vue'; 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 } from '@/account.js'; +import { signinRequired, updateAccount } from '@/account.js'; import { i18n } from '@/i18n.js'; const $i = signinRequired(); @@ -116,6 +120,10 @@ async function unregisterTOTP(): Promise<void> { os.apiWithDialog('i/2fa/unregister', { password: auth.result.password, token: auth.result.token, + }).then(res => { + updateAccount({ + twoFactorEnabled: false, + }); }).catch(error => { os.alert({ type: 'error', diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 1919f80864..81a8d474d2 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -44,6 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.keepOriginalUploading }}</template> <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> </MkSwitch> + <MkSwitch v-model="keepOriginalFilename"> + <template #label>{{ i18n.ts.keepOriginalFilename }}</template> + <template #caption>{{ i18n.ts.keepOriginalFilenameDescription }}</template> + </MkSwitch> <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> </MkSwitch> @@ -96,6 +100,7 @@ const meterStyle = computed(() => { }); const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); +const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename')); misskeyApi('drive').then(info => { capacity.value = info.capacity; diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index d13b6884bd..cfc63f2a08 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -50,12 +50,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div class="_gaps_s"> + <MkSwitch v-model="collapseRenotes"> + <template #label>{{ i18n.ts.collapseRenotes }}</template> + <template #caption>{{ i18n.ts.collapseRenotesDescription }}</template> + </MkSwitch> <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> - <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch> <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch> + <MkSwitch v-model="showReactionsCount">{{ i18n.ts.showReactionsCount }}</MkSwitch> <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch> <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch> <MkRadios v-model="reactionsDisplaySize"> @@ -131,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch> <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> <MkSwitch v-model="enableSeasonalScreenEffect">{{ i18n.ts.seasonalScreenEffect }}</MkSwitch> + <MkSwitch v-model="useNativeUIForVideoAudioPlayer">{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</MkSwitch> </div> <div> <MkRadios v-model="emojiStyle"> @@ -163,6 +168,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch> <MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> <MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch> + <MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch> </div> <MkSelect v-model="serverDisconnectedBehavior"> <template #label>{{ i18n.ts.whenServerDisconnected }}</template> @@ -281,6 +287,7 @@ const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm')); const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm')); +const showReactionsCount = computed(defaultStore.makeGetterSetter('showReactionsCount')); const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction')); const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); @@ -306,6 +313,8 @@ const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disable const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect')); const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); +const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer')); +const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -347,6 +356,7 @@ watch([ keepScreenOn, disableStreamingTimeline, enableSeasonalScreenEffect, + alwaysConfirmFollow, ], async () => { await reloadAsk(); }); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 0ab75b95a2..9804454e66 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -42,12 +42,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkFolder> + <template #icon><i class="ti ti-terminal-2"></i></template> + <template #label>{{ i18n.ts._plugin.viewLog }}</template> + + <div class="_gaps_s"> + <div class="_buttons"> + <MkButton inline @click="copy(pluginLogs.get(plugin.id)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + </div> + + <MkCode :code="pluginLogs.get(plugin.id)?.join('\n') ?? ''"/> + </div> + </MkFolder> + + <MkFolder> <template #icon><i class="ti ti-code"></i></template> <template #label>{{ i18n.ts._plugin.viewSource }}</template> <div class="_gaps_s"> <div class="_buttons"> - <MkButton inline @click="copy(plugin)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + <MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> </div> <MkCode :code="plugin.src ?? ''" lang="is"/> @@ -74,6 +87,7 @@ import { ColdDeviceStorage } from '@/store.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { pluginLogs } from '@/plugin.js'; const plugins = ref(ColdDeviceStorage.get('plugins')); @@ -87,8 +101,8 @@ async function uninstall(plugin) { }); } -function copy(plugin) { - copyToClipboard(plugin.src ?? ''); +function copy(text) { + copyToClipboard(text ?? ''); os.success(); } diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 942de19d82..b6f1043154 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -70,6 +70,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'animation', 'animatedMfm', 'advancedMfm', + 'showReactionsCount', 'loadRawImages', 'imageNewTab', 'dataSaver', diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 680934e7ce..37f6558d64 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -64,7 +64,34 @@ async function init() { // Googleニュース対策 if (text?.startsWith(`${title.value}.\n`)) noteText += text.replace(`${title.value}.\n`, ''); else if (text && title.value !== text) noteText += `${text}\n`; - if (url) noteText += `${url}`; + if (url) { + try { + // Normalize the URL to URL-encoded and puny-coded from with the URL constructor. + // + // It's common to use unicode characters in the URL for better visibility of URL + // like: https://ja.wikipedia.org/wiki/ミスキー + // or like: https://藍.moe/ + // However, in the MFM, the unicode characters must be URL-encoded to be parsed as `url` node + // like: https://ja.wikipedia.org/wiki/%E3%83%9F%E3%82%B9%E3%82%AD%E3%83%BC + // or like: https://xn--931a.moe/ + // Therefore, we need to normalize the URL to URL-encoded form. + // + // The URL constructor will parse the URL and normalize unicode characters + // in the host to punycode and in the path component to URL-encoded form. + // (see url.spec.whatwg.org) + // + // In addition, the current MFM renderer decodes the URL-encoded path and / punycode encoded host name so + // this normalization doesn't make the visible URL ugly. + // (see MkUrl.vue) + + noteText += new URL(url).href; + } catch { + // fallback to original URL if the URL is invalid. + // note that this is extremely rare since the `url` parameter is designed to share a URL and + // the URL constructor will throw TypeError only if failure, which means the URL is not valid. + noteText += url; + } + } initialText.value = noteText.trim(); if (visibility.value === 'specified') { diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 48dfc1fd44..98744c6318 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -48,7 +48,7 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { $i } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { antennasCache, userListsCache } from '@/cache.js'; +import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; @@ -173,9 +173,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { } async function chooseChannel(ev: MouseEvent): Promise<void> { - const channels = await misskeyApi('channels/my-favorites', { - limit: 100, - }); + const channels = await favoritedChannelsCache.fetch(); const items: MenuItem[] = [ ...channels.map(channel => { const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null; diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 89bb010dd6..d6ba397f1b 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -9,7 +9,10 @@ SPDX-License-Identifier: AGPL-3.0-only <XTimeline class="tl"/> <div class="shape1"></div> <div class="shape2"></div> - <img :src="misskeysvg" class="misskey"/> + <div class="logo-wrapper"> + <div class="powered-by">Powered by</div> + <img :src="misskeysvg" class="misskey"/> + </div> <div class="emojis"> <MkEmoji :normal="true" :noStyle="true" emoji="👍"/> <MkEmoji :normal="true" :noStyle="true" emoji="❤"/> @@ -39,11 +42,11 @@ import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import misskeysvg from '/client-assets/misskey.svg'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/scripts/misskey-api.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { instance as meta } from '@/instance.js'; -const meta = ref<Misskey.entities.MetaResponse>(); const instances = ref<Misskey.entities.FederationInstance[]>(); function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { @@ -53,10 +56,6 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string return getProxiedImageUrl(instance.iconUrl, 'preview'); } -misskeyApi('meta', { detail: true }).then(_meta => { - meta.value = _meta; -}); - misskeyApiGet('federation/instances', { sort: '+pubSub', limit: 20, @@ -113,14 +112,24 @@ misskeyApiGet('federation/instances', { opacity: 0.5; } - > .misskey { + > .logo-wrapper { position: fixed; - top: 42px; - left: 42px; - width: 140px; + top: 36px; + left: 36px; + flex: auto; + color: #fff; + user-select: none; + pointer-events: none; + + > .powered-by { + margin-bottom: 2px; + } - @media (max-width: 450px) { - width: 130px; + > .misskey { + width: 140px; + @media (max-width: 450px) { + width: 130px; + } } } diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index 9ba6a5885e..915fe35025 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="meta"> - <XSetup v-if="meta.requireSetup"/> +<div v-if="instance"> + <XSetup v-if="instance.requireSetup"/> <XEntrance v-else/> </div> </template> @@ -16,13 +16,13 @@ import * as Misskey from 'misskey-js'; import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { fetchInstance } from '@/instance.js'; -const meta = ref<Misskey.entities.MetaResponse | null>(null); +const instance = ref<Misskey.entities.MetaDetailed | null>(null); -misskeyApi('meta', { detail: true }).then(res => { - meta.value = res; +fetchInstance(true).then((res) => { + instance.value = res; }); const headerActions = computed(() => []); |