1
0
Files
pawkey-sk/packages/frontend/src/components/MkUrlPreview.vue
T
dakkar 3aeca799a5 fix url preview spacing for very narrow viewports
on wide viewports, the url preview only leaves space for a thumbnail
when such thumbnail exists.

on small phones, or in busy deck views, it always left that space

this was not very evident for simple previews (which are pretty much a
title line, a favicon, and the site name), but it became very obvious
with the note embedding in 2024.11

this patch makes the thumbnail space conditional on the existence of
the thumbnail, for narrow viewports as well (I pretty much copied the
bit of CSS from the non-narrow media query sections)
2025-01-02 17:40:36 +00:00

428 lines
11 KiB
Vue

<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<template v-if="player.url && playerEnabled">
<div
:class="$style.player"
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
>
<iframe
v-if="player.url.startsWith('http://') || player.url.startsWith('https://')"
sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin"
scrolling="no"
:allow="player.allow == null ? 'autoplay;encrypted-media;fullscreen' : player.allow.filter(x => ['autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share'].includes(x)).join(';')"
:class="$style.playerIframe"
:src="transformPlayerUrl(player.url)"
:style="{ border: 0 }"
></iframe>
<span v-else>invalid url</span>
</div>
<div :class="$style.action">
<MkButton :small="true" inline @click="playerEnabled = false">
<i class="ti ti-x"></i> {{ i18n.ts.disablePlayer }}
</MkButton>
</div>
</template>
<template v-else-if="tweetId && tweetExpanded">
<div ref="twitter">
<iframe
ref="tweet"
allow="fullscreen;web-share"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin"
scrolling="no"
:style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }"
:src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"
></iframe>
</div>
<div :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = false">
<i class="ti ti-x"></i> {{ i18n.ts.close }}
</MkButton>
</div>
</template>
<div v-else-if="theNote" :class="[$style.link, { [$style.compact]: compact }]"><XNoteSimple :note="theNote" :class="$style.body"/></div>
<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
</div>
<article :class="$style.body">
<header :class="$style.header">
<h1 v-if="unknownUrl" :class="$style.title">{{ url }}</h1>
<h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1>
<h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1>
</header>
<p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.failedToPreviewUrl }}</p>
<p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p>
<p v-else-if="description" :class="$style.text" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
<footer :class="$style.footer">
<img v-if="icon" :class="$style.siteIcon" :src="icon"/>
<p v-if="unknownUrl" :class="$style.siteName">{{ requestUrl.host }}</p>
<p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p>
<p v-else :class="$style.siteName" :title="sitename ?? requestUrl.host">{{ sitename ?? requestUrl.host }}</p>
</footer>
</article>
</component>
<template v-if="showActions">
<div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true">
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
</MkButton>
</div>
<div v-if="!playerEnabled && player.url" :class="$style.action">
<MkButton :small="true" inline @click="playerEnabled = true">
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
</MkButton>
<MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()">
<i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }}
</MkButton>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onDeactivated, onUnmounted, ref, watch } from 'vue';
import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js';
import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkButton from '@/components/MkButton.vue';
import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
import { defaultStore } from '@/store.js';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const XNoteSimple = defineAsyncComponent(() =>
(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteSimple.vue') :
(defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNoteSimple.vue') :
null
);
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
const props = withDefaults(defineProps<{
url: string;
detail?: boolean;
compact?: boolean;
showAsQuote?: boolean;
showActions?: boolean;
}>(), {
detail: false,
compact: false,
showAsQuote: false,
showActions: true,
});
const MOBILE_THRESHOLD = 500;
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const fetching = ref(true);
const title = ref<string | null>(null);
const description = ref<string | null>(null);
const thumbnail = ref<string | null>(null);
const icon = ref<string | null>(null);
const sitename = ref<string | null>(null);
const sensitive = ref<boolean>(false);
const activityPub = ref<string | null>(null);
const player = ref({
url: null,
width: null,
height: null,
} as SummalyResult['player']);
const playerEnabled = ref(false);
const tweetId = ref<string | null>(null);
const tweetExpanded = ref(props.detail);
const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
const tweetHeight = ref(150);
const unknownUrl = ref(false);
const theNote = ref<Misskey.entities.Note | null>(null);
onDeactivated(() => {
playerEnabled.value = false;
});
watch(activityPub, async (uri) => {
if (!props.showAsQuote) return;
if (!uri) return;
try {
const response = await misskeyApi('ap/show', { uri });
if (response.type !== 'Note') return;
theNote.value = response['object'];
} catch (err) {
if (_DEV_) {
console.error(`failed to extract note for preview of ${uri}`, err);
}
}
});
const requestUrl = new URL(props.url);
if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url');
if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com' || requestUrl.hostname === 'x.com' || requestUrl.hostname === 'mobile.x.com') {
const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
if (m) tweetId.value = m[1];
}
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com';
}
requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
.then(res => {
if (!res.ok) {
if (_DEV_) {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
}
return null;
}
return res.json();
})
.then((info: SummalyResult | null) => {
if (!info || info.url == null) {
fetching.value = false;
unknownUrl.value = true;
return;
}
fetching.value = false;
unknownUrl.value = false;
title.value = info.title;
description.value = info.description;
thumbnail.value = info.thumbnail;
icon.value = info.icon;
sitename.value = info.sitename;
player.value = info.player;
sensitive.value = info.sensitive ?? false;
activityPub.value = info.activityPub;
});
function adjustTweetHeight(message: MessageEvent) {
if (message.origin !== 'https://platform.twitter.com') return;
const embed = message.data?.['twttr.embed'];
if (embed?.method !== 'twttr.private.resize') return;
if (embed?.id !== embedId) return;
const height = embed?.params[0]?.height;
if (height) tweetHeight.value = height;
}
function openPlayer(): void {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
url: requestUrl.href,
}, {
closed: () => {
dispose();
},
});
}
window.addEventListener('message', adjustTweetHeight);
onUnmounted(() => {
window.removeEventListener('message', adjustTweetHeight);
});
</script>
<style lang="scss" module>
.player {
position: relative;
width: 100%;
}
.disablePlayer {
position: absolute;
top: -1.5em;
right: 0;
font-size: 1em;
width: 1.5em;
height: 1.5em;
padding: 0;
margin: 0;
color: var(--MI_THEME-fg);
background: rgba(128, 128, 128, 0.2);
opacity: 0.7;
&:hover {
opacity: 0.9;
}
}
.playerIframe {
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
.link {
position: relative;
display: block;
font-size: 14px;
box-shadow: 0 0 0 1px var(--MI_THEME-divider);
border-radius: var(--MI-radius-sm);
overflow: clip;
&:hover {
text-decoration: none;
border-color: rgba(0, 0, 0, 0.2);
> .body > .header > .title {
text-decoration: underline;
}
}
&.compact {
> .body {
> .header .title, .text, .footer {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
.thumbnail {
position: absolute;
width: 100px;
height: 100%;
background-position: center;
background-size: cover;
background-color: var(--MI_THEME-bg);
display: flex;
justify-content: center;
align-items: center;
& + .body {
left: 100px;
width: calc(100% - 100px);
}
}
.body {
position: relative;
box-sizing: border-box;
padding: 16px;
}
.header {
margin-bottom: 8px;
}
.title {
margin: 0;
font-size: 1em;
}
.text {
margin: 0;
font-size: 0.8em;
}
.footer {
margin-top: 8px;
height: 16px;
}
.siteIcon {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: top;
}
.siteName {
display: inline-block;
margin: 0;
font-size: 0.8em;
line-height: 16px;
vertical-align: top;
}
.action {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
@container (max-width: 400px) {
.link {
font-size: 12px;
}
.thumbnail {
height: 80px;
}
.body {
padding: 12px;
}
}
@container (max-width: 350px) {
.link {
font-size: 10px;
&.compact {
> .thumbnail {
position: absolute;
width: 56px;
height: 100%;
& + .body {
left: 56px;
width: calc(100% - 56px);
}
}
> .body {
padding: 4px;
> .header {
margin-bottom: 2px;
}
> .footer {
margin-top: 2px;
}
}
}
}
.thumbnail {
height: 70px;
}
.body {
padding: 8px;
}
.header {
margin-bottom: 4px;
}
.footer {
margin-top: 4px;
}
.siteIcon {
width: 12px;
height: 12px;
}
}
</style>