diff options
| author | PrivateGER <privateger@privateger.me> | 2024-10-06 23:13:10 +0200 |
|---|---|---|
| committer | PrivateGER <privateger@privateger.me> | 2024-10-06 23:13:10 +0200 |
| commit | fadae347ffd52a3610ef2e6ce160ade32d3dac31 (patch) | |
| tree | 2731c25215245ba6c9cf4b8738f6868cf33afb78 /packages/frontend/src | |
| parent | Move text into translation files (diff) | |
| parent | merge: Add option to reject reports from an instance (Resolves #579, #715, #7... (diff) | |
| download | sharkey-fadae347ffd52a3610ef2e6ce160ade32d3dac31.tar.gz sharkey-fadae347ffd52a3610ef2e6ce160ade32d3dac31.tar.bz2 sharkey-fadae347ffd52a3610ef2e6ce160ade32d3dac31.zip | |
Merge branch 'develop' of https://activitypub.software/TransFem-org/Sharkey into feat/instance-admin-ui
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/boot/main-boot.ts | 28 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkLink.vue | 14 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPostForm.vue | 19 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUrlWarningDialog.vue | 131 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkVisitorDashboard.vue | 1 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/MkUrl.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/const.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/src/index.html | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/moderation.vue | 9 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/modlog.ModLog.vue | 8 | ||||
| -rw-r--r-- | packages/frontend/src/pages/instance-info.vue | 38 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/home.vue | 29 | ||||
| -rw-r--r-- | packages/frontend/src/plugin.ts | 13 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/warning-external-website.ts | 51 | ||||
| -rw-r--r-- | packages/frontend/src/store.ts | 4 |
15 files changed, 303 insertions, 52 deletions
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index c10930a038..5ff998fac4 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -216,19 +216,25 @@ export async function mainBoot() { claimAchievement('collectAchievements30'); } - window.setInterval(() => { - if (Math.floor(Math.random() * 20000) === 0) { - claimAchievement('justPlainLucky'); - } - }, 1000 * 10); + if (!claimedAchievements.includes('justPlainLucky')) { + window.setInterval(() => { + if (Math.floor(Math.random() * 20000) === 0) { + claimAchievement('justPlainLucky'); + } + }, 1000 * 10); + } - window.setTimeout(() => { - claimAchievement('client30min'); - }, 1000 * 60 * 30); + if (!claimedAchievements.includes('client30min')) { + window.setTimeout(() => { + claimAchievement('client30min'); + }, 1000 * 60 * 30); + } - window.setTimeout(() => { - claimAchievement('client60min'); - }, 1000 * 60 * 60); + if (!claimedAchievements.includes('client60min')) { + window.setTimeout(() => { + claimAchievement('client60min'); + }, 1000 * 60 * 60); + } // 邪魔 //const lastUsed = miLocalStorage.getItem('lastUsed'); diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index d2819f9f4c..b04edd1150 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target" :behavior="props.navigationBehavior" :title="url" - @click.prevent="self ? true : promptConfirm()" + @click.prevent="self ? true : warningExternalWebsite(url)" @click.stop > <slot></slot> @@ -23,7 +23,7 @@ import { useTooltip } from '@/scripts/use-tooltip.js'; import * as os from '@/os.js'; import { isEnabledUrlPreview } from '@/instance.js'; import { MkABehavior } from '@/components/global/MkA.vue'; -import { i18n } from '@/i18n.js'; +import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; const props = withDefaults(defineProps<{ url: string; @@ -49,16 +49,6 @@ if (isEnabledUrlPreview.value) { }); }); } - -async function promptConfirm() { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.tsx.confirmRemoteUrl({ x: props.url }), - plain: true, - }); - if (canceled) return; - window.open(props.url, '_blank', 'nofollow noopener popup=false'); -} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index dc3f3aa94c..add5296f0a 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -630,11 +630,22 @@ async function onPaste(ev: ClipboardEvent) { if (paste.length > 1000) { ev.preventDefault(); - os.confirm({ - type: 'info', + os.actions({ + type: 'question', text: i18n.ts.attachAsFileQuestion, - }).then(({ canceled }) => { - if (canceled) { + actions: [ + { + value: 'yes', + text: i18n.ts.yes, + primary: true, + }, + { + value: 'no', + text: i18n.ts.no, + }, + ], + }).then(({ result }) => { + if (result !== 'yes') { insertTextAtCursor(textareaEl.value, paste); return; } diff --git a/packages/frontend/src/components/MkUrlWarningDialog.vue b/packages/frontend/src/components/MkUrlWarningDialog.vue new file mode 100644 index 0000000000..5a37a36ee5 --- /dev/null +++ b/packages/frontend/src/components/MkUrlWarningDialog.vue @@ -0,0 +1,131 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')"> + <div :class="$style.root" class="_gaps"> + <div class="_gaps_s"> + <div :class="$style.header"> + <div :class="$style.icon"> + <i class="ti ti-alert-triangle"></i> + </div> + <div :class="$style.title">{{ i18n.ts._externalNavigationWarning.title }}</div> + </div> + <div><Mfm :text="i18n.tsx._externalNavigationWarning.description({ host: instanceName })"/></div> + <div class="_monospace" :class="$style.urlAddress">{{ url }}</div> + <div> + <MkSwitch v-model="trustThisDomain">{{ i18n.ts._externalNavigationWarning.trustThisDomain }}</MkSwitch> + </div> + </div> + <div :class="$style.buttons"> + <MkButton data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton> + <MkButton data-cy-modal-dialog-ok inline primary rounded @click="ok"><i class="ti ti-external-link"></i> {{ i18n.ts.open }}</MkButton> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue'; +import { instanceName } from '@/config.js'; +import MkModal from '@/components/MkModal.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { i18n } from '@/i18n.js'; +import { defaultStore } from '@/store.js'; + +type Result = string | number | true | null; + +const props = defineProps<{ + url: string; +}>(); + +const emit = defineEmits<{ + (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void; + (ev: 'closed'): void; +}>(); + +const modal = shallowRef<InstanceType<typeof MkModal>>(); +const trustThisDomain = ref(false); + +const domain = computed(() => new URL(props.url).hostname); + +// overload function を使いたいので lint エラーを無視する +function done(canceled: true): void; +function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare +function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare + emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result }); + modal.value?.close(); +} + +async function ok() { + const result = true; + if (!defaultStore.state.trustedDomains.includes(domain.value) && trustThisDomain.value) { + await defaultStore.set('trustedDomains', defaultStore.state.trustedDomains.concat(domain.value)); + } + done(false, result); +} + +function cancel() { + done(true); +} + +function onKeydown(evt: KeyboardEvent) { + if (evt.key === 'Escape') cancel(); +} + +onMounted(() => { + document.addEventListener('keydown', onKeydown); +}); + +onBeforeUnmount(() => { + document.removeEventListener('keydown', onKeydown); +}); +</script> + +<style lang="scss" module> +.root { + position: relative; + margin: auto; + padding: 32px; + width: 100%; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + background: var(--panel); + border-radius: 16px; +} + +.header { + display: flex; + align-items: center; + gap: 0.75em; +} + +.icon { + font-size: 18px; + color: var(--warn); +} + +.title { + font-weight: bold; + font-size: 1.1em; +} + +.urlAddress { + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--divider); + overflow-x: auto; + white-space: nowrap; +} + +.buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: right; +} +</style> diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index b154f7a5b3..ff2e27aaf8 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -142,6 +142,7 @@ function showMenu(ev: MouseEvent) { height: 32px; border-radius: var(--radius-sm); font-size: 18px; + z-index: 50; } .mainFg { diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 15595ba515..1dec8ad28c 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only :is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target" :behavior="props.navigationBehavior" @contextmenu.stop="() => {}" + @click.prevent="self ? true : warningExternalWebsite(props.url)" @click.stop > <template v-if="!self"> @@ -34,6 +35,7 @@ import { useTooltip } from '@/scripts/use-tooltip.js'; import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; import { isEnabledUrlPreview } from '@/instance.js'; import { MkABehavior } from '@/components/global/MkA.vue'; +import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; const props = withDefaults(defineProps<{ url: string; diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index c94c0d4408..058db9b981 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -160,9 +160,9 @@ export const ROLE_POLICIES = [ export const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP'; export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM'; -export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://launcher.moe/error.png'; -export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://launcher.moe/missingpage.webp'; -export const DEFAULT_INFO_IMAGE_URL = 'https://launcher.moe/nothinghere.png'; +export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/status/error.png'; +export const DEFAULT_NOT_FOUND_IMAGE_URL = '/status/missingpage.webp'; +export const DEFAULT_INFO_IMAGE_URL = '/status/nothinghere.png'; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse']; export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = { diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 733116b75f..fdeb642c70 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -20,7 +20,7 @@ worker-src 'self'; script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh; style-src 'self' 'unsafe-inline'; - img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com; + img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org; frame-src *;" diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 6297b9a182..0a5b06a969 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -50,6 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> </MkTextarea> + <MkTextarea v-model="trustedLinkUrlPatterns"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.trustedLinkUrlPatterns }}</template> + <template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template> + </MkTextarea> + <MkTextarea v-model="sensitiveWords"> <template #label>{{ i18n.ts.sensitiveWords }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> @@ -105,6 +111,7 @@ const bubbleTimeline = ref<string>(''); const tosUrl = ref<string | null>(null); const privacyPolicyUrl = ref<string | null>(null); const inquiryUrl = ref<string | null>(null); +const trustedLinkUrlPatterns = ref<string>(''); async function init() { const meta = await misskeyApi('admin/meta'); @@ -120,6 +127,7 @@ async function init() { bubbleTimeline.value = meta.bubbleInstances.join('\n'); bubbleTimelineEnabled.value = meta.policies.btlAvailable; inquiryUrl.value = meta.inquiryUrl; + trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n'); } function save() { @@ -135,6 +143,7 @@ function save() { hiddenTags: hiddenTags.value.split('\n'), preservedUsernames: preservedUsernames.value.split('\n'), bubbleInstances: bubbleTimeline.value.split('\n'), + trustedLinkUrlPatterns: trustedLinkUrlPatterns.value.split('\n'), }).then(() => { fetchInstance(true); }); diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index f6f276de53..9fe804b2bd 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -23,6 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only 'markSensitiveDriveFile', 'resetPassword', 'suspendRemoteInstance', + 'setRemoteInstanceNSFW', + 'unsetRemoteInstanceNSFW', + 'rejectRemoteInstanceReports', + 'acceptRemoteInstanceReports', ].includes(log.type), [$style.logRed]: [ 'suspend', @@ -61,6 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'unmarkSensitiveDriveFile'">: @{{ log.info.fileUserUsername }}{{ log.info.fileUserHost ? '@' + log.info.fileUserHost : '' }}</span> <span v-else-if="log.type === 'suspendRemoteInstance'">: {{ log.info.host }}</span> <span v-else-if="log.type === 'unsuspendRemoteInstance'">: {{ log.info.host }}</span> + <span v-else-if="log.type === 'setRemoteInstanceNSFW'">: {{ log.info.host }}</span> + <span v-else-if="log.type === 'unsetRemoteInstanceNSFW'">: {{ log.info.host }}</span> + <span v-else-if="log.type === 'rejectRemoteInstanceReports'">: {{ log.info.host }}</span> + <span v-else-if="log.type === 'acceptRemoteInstanceReports'">: {{ log.info.host }}</span> <span v-else-if="log.type === 'createGlobalAnnouncement'">: {{ log.info.announcement.title }}</span> <span v-else-if="log.type === 'updateGlobalAnnouncement'">: {{ log.info.before.title }}</span> <span v-else-if="log.type === 'deleteGlobalAnnouncement'">: {{ log.info.announcement.title }}</span> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 28ad2da0a7..95371036aa 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -49,10 +49,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-if="suspensionState === 'none'" inline :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton> <MkButton v-if="suspensionState !== 'none'" inline :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton> </div> - <MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> - <MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> - <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch> - <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch> + <MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo> + <MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> + <MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo> + <MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> + <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch> + <MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch> + <MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo> + <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch> <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> <MkTextarea v-model="moderationNote" manualSave> <template #label>{{ i18n.ts.moderationNote }}</template> @@ -160,6 +164,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; import { dateString } from '@/filters/date.js'; import MkTextarea from '@/components/MkTextarea.vue'; +import MkInfo from '@/components/MkInfo.vue'; const props = defineProps<{ host: string; @@ -174,10 +179,26 @@ const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'au const isBlocked = ref(false); const isSilenced = ref(false); const isNSFW = ref(false); +const rejectReports = ref(false); const isMediaSilenced = ref(false); const faviconUrl = ref<string | null>(null); const moderationNote = ref(''); +const baseDomains = computed(() => { + const domains: string[] = []; + + const parts = props.host.toLowerCase().split('.'); + for (let s = 1; s < parts.length; s++) { + const domain = parts.slice(s).join('.'); + domains.push(domain); + } + + return domains; +}); +const isBaseBlocked = computed(() => meta.value && baseDomains.value.some(d => meta.value?.blockedHosts.includes(d))); +const isBaseSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.silencedHosts.includes(d))); +const isBaseMediaSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.mediaSilencedHosts.includes(d))); + const usersPagination = { endpoint: iAmModerator ? 'admin/show-users' : 'users' as const, limit: 10, @@ -204,6 +225,7 @@ async function fetch(): Promise<void> { isBlocked.value = instance.value?.isBlocked ?? false; isSilenced.value = instance.value?.isSilenced ?? false; isNSFW.value = instance.value?.isNSFW ?? false; + rejectReports.value = instance.value?.rejectReports ?? false; isMediaSilenced.value = instance.value?.isMediaSilenced ?? false; faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview'); moderationNote.value = instance.value?.moderationNote ?? ''; @@ -264,6 +286,14 @@ async function toggleNSFW(): Promise<void> { }); } +async function toggleRejectReports(): Promise<void> { + if (!instance.value) throw new Error('No instance?'); + await misskeyApi('admin/federation/update-instance', { + host: instance.value.host, + rejectReports: rejectReports.value, + }); +} + function refreshMetadata(): void { if (!instance.value) throw new Error('No instance?'); misskeyApi('admin/federation/refresh-remote-instance-metadata', { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index b997fe1c3f..e82ec0cb97 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -30,7 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> </div> - <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div v-if="$i && $i.id != user.id" class="info-badges"> + <span v-if="user.isFollowed">{{ i18n.ts.followsYou }}</span> + <span v-if="user.isMuted">{{ i18n.ts.muted }}</span> + <span v-if="user.isRenoteMuted">{{ i18n.ts.renoteMuted }}</span> + <span v-if="user.isBlocking">{{ i18n.ts.blocked }}</span> + </div> <div class="actions"> <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> @@ -445,15 +450,25 @@ onUnmounted(() => { background: linear-gradient(transparent, rgba(#000, 0.7)); } - > .followed { + > .info-badges { position: absolute; top: 12px; left: 12px; - padding: 4px 8px; - color: #fff; - background: rgba(0, 0, 0, 0.7); - font-size: 0.7em; - border-radius: var(--radius-sm); + + display: flex; + flex-direction: row; + + > * { + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: var(--radius-sm); + } + + > :not(:first-child) { + margin-left: 8px; + } } > .actions { diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index 9640c988eb..c0034d414c 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -9,6 +9,7 @@ import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js'; +import { warningExternalWebsite } from '@/scripts/warning-external-website.js'; const parser = new Parser(); const pluginContexts = new Map<string, Interpreter>(); @@ -92,16 +93,8 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s registerPageViewInterruptor({ pluginId: opts.plugin.id, handler }); }), 'Plugin:open_url': values.FN_NATIVE(([url]) => { - (async () => { - utils.assertString(url); - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.tsx.confirmRemoteUrl({ x: url.value }), - plain: true, - }); - if (canceled) return; - window.open(url.value, '_blank', 'noopener'); - })(); + utils.assertString(url); + warningExternalWebsite(url.value); }), 'Plugin:config': values.OBJ(config), }; diff --git a/packages/frontend/src/scripts/warning-external-website.ts b/packages/frontend/src/scripts/warning-external-website.ts new file mode 100644 index 0000000000..5ef003cb01 --- /dev/null +++ b/packages/frontend/src/scripts/warning-external-website.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { instance } from '@/instance.js'; +import { defaultStore } from '@/store.js'; +import * as os from '@/os.js'; +import MkUrlWarningDialog from '@/components/MkUrlWarningDialog.vue'; + +const extractDomain = /^(https?:\/\/|\/\/)?([^@/\s]+@)?(www\.)?([^:/\s]+)/i; +const isRegExp = /^\/(.+)\/(.*)$/; + +export async function warningExternalWebsite(url: string) { + const domain = extractDomain.exec(url)?.[4]; + + if (!domain) return false; + + const isTrustedByInstance = instance.trustedLinkUrlPatterns.some(expression => { + const r = isRegExp.exec(expression); + + if (r) { + return new RegExp(r[1], r[2]).test(url); + } else if (expression.includes(' ')) { + return expression.split(' ').every(keyword => url.includes(keyword)); + } else { + return domain.endsWith(expression); + } + }); + + const isTrustedByUser = defaultStore.reactiveState.trustedDomains.value.includes(domain); + + if (!isTrustedByInstance && !isTrustedByUser) { + const confirm = await new Promise<{ canceled: boolean }>(resolve => { + const { dispose } = os.popup(MkUrlWarningDialog, { + url, + }, { + done: result => { + resolve(result ?? { canceled: true }); + }, + closed: () => dispose(), + }); + }); + + if (confirm.canceled) return false; + + return window.open(url, '_blank', 'nofollow noopener popup=false'); + } + + return window.open(url, '_blank', 'nofollow noopener popup=false'); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 036e43a4b6..ab5fbf0dd1 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -165,6 +165,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: 'public' as 'public' | 'home' | 'followers', }, + trustedDomains: { + where: 'account', + default: [] as string[], + }, menu: { where: 'deviceAccount', |