This commit is contained in:
mq1 2026-06-25 14:06:12 +09:00 committed by GitHub
commit d2fdbb9c39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 90 additions and 16 deletions

View file

@ -15,6 +15,10 @@
### General
- Feat: ジョブキュー管理画面からキューの一時停止/再開ができるように
- Feat: アンテナのタイムラインから個別のノートを削除できるように
- Enhance: リンクプレビューで大きいカード表示に対応
- `twitter:card = summary_large_image` が設定されているサイトの場合、リンクカードを拡大して表示します(メディアの添付もあるなどの条件によっては拡大表示をしない場合があります)
- ユーザー側で無効化することも可能です
- Fix: コンパネからrootユーザーのパスワードをリセットしようとした際にエラーが通知されない問題を修正
- Feat: ノート検索で投稿日時の期間を条件に加えられるように(#16035)
- Fix: コンパネからrootユーザーのパスワードをリセットしようとした際にエラーが通知されない問題を修正

View file

@ -900,6 +900,7 @@ customCss: "カスタムCSS"
customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。"
global: "グローバル"
squareAvatars: "アイコンを四角形で表示"
forceCompactUrlPreview: "URLプレビューを常にコンパクト表示にする"
sent: "送信"
received: "受信"
searchResult: "検索結果"

View file

@ -26,6 +26,7 @@ type ProxyQuery = {
static?: string;
preview?: string;
badge?: string;
thumbnail?: string;
origin?: string;
url?: string;
};
@ -131,7 +132,7 @@ export class FileServerProxyHandler {
): Promise<IImageStreamable> {
const query = request.query;
const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query;
const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query || 'thumbnail' in query;
const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
if (requiresImageConversion && !isConvertibleImage) {
throw new StatusError('Unexpected mime', 404);
@ -141,6 +142,10 @@ export class FileServerProxyHandler {
return this.processEmojiOrAvatar(file, query);
}
if ('thumbnail' in query) {
return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 1280, 720);
}
if ('static' in query) {
return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
}

View file

@ -15,7 +15,7 @@ export class MediaProxy {
this.url = url;
}
public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar' | 'thumbnail', mustOrigin = false, noFallback = false): string {
const localProxy = `${this.url}/proxy`;
let _imageUrl = imageUrl;
@ -26,11 +26,15 @@ export class MediaProxy {
return `${mustOrigin ? localProxy : this.serverMetadata.mediaProxy}/${
type === 'preview' ? 'preview.webp'
: type === 'thumbnail' ? 'thumbnail.webp'
: 'image.webp'
}?${query({
url: _imageUrl,
...(!noFallback ? { 'fallback': '1' } : {}),
...(type ? { [type]: '1' } : {}),
// thumbnail を理解しない外部プロキシでも GIF アニメ解除と縮小がかかるよう static=1 をフォールバックとして併用
...(type === 'thumbnail'
? { thumbnail: '1', static: '1' }
: type ? { [type]: '1' } : {}),
...(mustOrigin ? { origin: '1' } : {}),
})}`;
}

View file

@ -23,12 +23,11 @@ import { useTooltip } from '@/composables/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
const props = withDefaults(defineProps<{
const props = defineProps<{
url: string;
rel?: null | string;
navigationBehavior?: MkABehavior;
}>(), {
});
}>();
const maybeRelativeUrl = maybeMakeRelative(props.url, local);
const self = maybeRelativeUrl !== props.url;

View file

@ -100,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :forceCompactCard="(urls?.length ?? 0) >= 2 || (appearNote.files != null && appearNote.files.length > 0)" :class="$style.urlPreview"/>
</div>
<div v-if="appearNote.renoteId" :class="$style.quote"><MkNoteSimple :note="appearNote?.renote ?? null" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">

View file

@ -124,7 +124,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :forceCompactCard="(urls?.length ?? 0) >= 2 || (appearNote.files != null && appearNote.files.length > 0)" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<template v-if="player.url && playerEnabled">
<div 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`"
@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-x"></i> {{ i18n.ts.disablePlayer }}
</MkButton>
</div>
</template>
<template v-else-if="tweetId && tweetExpanded">
</div>
<div v-else-if="tweetId && tweetExpanded">
<div ref="twitter">
<iframe
ref="tweet"
@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-x"></i> {{ i18n.ts.close }}
</MkButton>
</div>
</template>
</div>
<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreviewThumbnail ? '' : { backgroundImage: `url('${thumbnail}')` }">
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact, [$style.large]: isLargeImage }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="displayThumbnail ? { backgroundImage: `url('${displayThumbnail}')` } : ''">
</div>
<article :class="$style.body">
<header :class="$style.header">
@ -101,10 +101,12 @@ const props = withDefaults(defineProps<{
detail?: boolean;
compact?: boolean;
showActions?: boolean;
forceCompactCard?: boolean;
}>(), {
detail: false,
compact: false,
showActions: true,
forceCompactCard: false,
});
const MOBILE_THRESHOLD = 500;
@ -119,9 +121,33 @@ const summalyResult = ref<SummalyResult | null>(null);
const title = computed(() => summalyResult.value?.title ?? null);
const description = computed(() => summalyResult.value?.description ?? null);
const thumbnail = computed(() => summalyResult.value?.thumbnail ?? null);
const thumbnailStyle = computed(() => summalyResult.value?.thumbnailStyle ?? null);
const icon = computed(() => summalyResult.value?.icon ?? null);
const sitename = computed(() => summalyResult.value?.sitename ?? null);
const sensitive = computed(() => summalyResult.value?.sensitive ?? false);
const isLargeImage = computed(() =>
thumbnail.value != null &&
tweetId.value == null &&
!sensitive.value &&
thumbnailStyle.value === 'summary_large_image' &&
!prefer.s.forceCompactUrlPreview &&
!props.forceCompactCard,
);
const displayThumbnail = computed(() => {
if (!thumbnail.value || prefer.s.dataSaver.urlPreviewThumbnail) return null;
if (!isLargeImage.value) return thumbnail.value;
// large card: preview=1 thumbnail=1 (1280x720, GIF)
// thumbnail GIF static=1
try {
const u = new URL(thumbnail.value);
u.searchParams.delete('preview');
u.searchParams.set('thumbnail', '1');
u.searchParams.set('static', '1');
return u.toString();
} catch {
return thumbnail.value;
}
});
const player = computed(() => summalyResult.value?.player ?? { url: null, width: null, height: null });
const playerEnabled = ref(false);
const tweetId = ref<string | null>(null);
@ -259,6 +285,24 @@ onUnmounted(() => {
}
}
}
&.large {
> .thumbnail {
position: relative;
width: 100%;
height: auto;
aspect-ratio: 1.91;
& + .body {
left: 0;
width: 100%;
}
}
> .body {
padding: 16px;
}
}
}
.thumbnail {

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/>
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false" forceCompactCard/>
</Transition>
</div>
</template>

View file

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :forceCompactCard="urls.length >= 2 || message.file != null" style="margin: 8px 0;"/>
<div :class="$style.footer">
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
<MkTime :class="$style.time" :time="message.createdAt"/>

View file

@ -324,6 +324,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['urlpreview', 'link', 'preview', 'card', 'large', 'compact']">
<MkPreferenceContainer k="forceCompactUrlPreview">
<MkSwitch v-model="forceCompactUrlPreview" :disabled="!instance.enableUrlPreview || dataSaver.disableUrlPreview">
<template #label><SearchLabel>{{ i18n.ts.forceCompactUrlPreview }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</div>
</MkFolder>
@ -936,6 +944,7 @@ const reactionsDisplaySize = prefer.model('reactionsDisplaySize');
const limitWidthOfReaction = prefer.model('limitWidthOfReaction');
const squareAvatars = prefer.model('squareAvatars');
const enableSeasonalScreenEffect = prefer.model('enableSeasonalScreenEffect');
const forceCompactUrlPreview = prefer.model('forceCompactUrlPreview');
const showAvatarDecorations = prefer.model('showAvatarDecorations');
const nsfw = prefer.model('nsfw');
const emojiStyle = prefer.model('emojiStyle');
@ -998,6 +1007,7 @@ watch([
limitWidthOfReaction,
instanceTicker,
squareAvatars,
forceCompactUrlPreview,
highlightSensitiveMedia,
enableSeasonalScreenEffect,
chatShowSenderName,

View file

@ -339,6 +339,9 @@ export const PREF_DEF = definePreferences({
useGroupedNotifications: {
default: true,
},
forceCompactUrlPreview: {
default: false,
},
dataSaver: {
default: {
media: false,

View file

@ -3612,6 +3612,10 @@ export interface Locale extends ILocale {
*
*/
"squareAvatars": string;
/**
* URLプレビューを常にコンパクト表示にする
*/
"forceCompactUrlPreview": string;
/**
*
*/