From 47986eb6f508c3ee8a8666b30306794c03cb2431 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight <saschanaz@outlook.com> Date: Sun, 6 Aug 2023 22:44:00 +0200 Subject: [PATCH] feat(frontend/MkUrlPreview): support expanding ActivityPub notes --- locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + packages/frontend/package.json | 2 +- packages/frontend/src/components/MkNote.vue | 19 +--- .../frontend/src/components/MkNoteSimple.vue | 15 ++- .../frontend/src/components/MkUrlPreview.vue | 97 +++++++++++++------ packages/frontend/test/url-preview.test.ts | 57 +++++++++-- pnpm-lock.yaml | 2 +- 8 files changed, 135 insertions(+), 59 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 4dceec6050..3ec926b92f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -589,6 +589,7 @@ export interface Locale { "enablePlayer": string; "disablePlayer": string; "expandTweet": string; + "expandNote": string; "themeEditor": string; "description": string; "describeFile": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cab5c8f97a..d31db56fd4 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -586,6 +586,7 @@ useCw: "å†…å®¹ã‚’éš ã™" enablePlayer: "プレイヤーを開ã" disablePlayer: "プレイヤーを閉ã˜ã‚‹" expandTweet: "ツイートを展開ã™ã‚‹" +expandNote: "ノートを展開ã™ã‚‹" themeEditor: "テーマエディター" description: "説明" describeFile: "ã‚ャプションを付ã‘ã‚‹" diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 7d3c8b9767..79a6a5b203 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -134,7 +134,7 @@ "start-server-and-test": "2.0.0", "storybook": "7.0.27", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", - "summaly": "github:misskey-dev/summaly", + "summaly": "github:misskey-dev/summaly#d2d8db49943ccb201c1b1b283e9d0a630519fac7", "vite-plugin-turbosnap": "1.0.2", "vitest": "0.33.0", "vitest-fetch-mock": "0.2.2", diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 6cd1a4c4b5..a6e1058992 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> - <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + <MkNoteSimple v-if="appearNote.renote" :note="appearNote.renote" :quoted="true"/> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> </button> @@ -758,17 +758,6 @@ function showReactions(): void { font-size: 80%; } -.quote { - padding: 8px 0; -} - -.quoteNote { - padding: 16px; - border: dashed 1px var(--renote); - border-radius: 8px; - overflow: clip; -} - .channel { opacity: 0.7; font-size: 80%; @@ -905,12 +894,6 @@ function showReactions(): void { } } -@container (max-width: 250px) { - .quoteNote { - padding: 12px; - } -} - .muted { padding: 8px; text-align: center; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 9648b7230a..c6c843acd2 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div :class="[$style.root, quoted ? $style.quoted : null]"> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> @@ -32,6 +32,7 @@ import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; pinned?: boolean; + quoted?: boolean; }>(); const showContent = $ref(false); @@ -80,12 +81,24 @@ const showContent = $ref(false); padding: 0; } +.quoted { + margin: 8px 0; + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + overflow: clip; +} + @container (min-width: 250px) { .avatar { margin: 0 10px 0 0; width: 40px; height: 40px; } + + .quoted { + padding: 12px; + } } @container (min-width: 350px) { diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 1a194ae9db..798feddc56 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -26,13 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> </template> -<template v-else-if="tweetId && tweetExpanded"> - <div ref="twitter"> +<template v-else-if="postExpanded"> + <div v-if="tweetId" ref="twitter"> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> </div> + <MkNoteSimple v-else-if="note" :note="note" :quoted="true"/> <div :class="$style.action"> - <MkButton :small="true" inline @click="tweetExpanded = false"> - <i class="ti ti-x"></i> {{ i18n.ts.close }} + <MkButton :small="true" inline @click="postExpanded = false"> + <i v-if="tweetId" class="ti ti-x"></i> {{ i18n.ts.close }} </MkButton> </div> </template> @@ -59,10 +60,15 @@ SPDX-License-Identifier: AGPL-3.0-only </component> <template v-if="showActions"> <div v-if="tweetId" :class="$style.action"> - <MkButton :small="true" inline @click="tweetExpanded = true"> + <MkButton :small="true" inline @click="postExpanded = true"> <i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }} </MkButton> </div> + <div v-if="noteUrl || note" :class="$style.action"> + <MkButton :small="true" inline @click="resolveNote()"> + {{ i18n.ts.expandNote }} + </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 }} @@ -78,11 +84,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, onUnmounted } from 'vue'; import type { summaly } from 'summaly'; +import type * as misskey from 'misskey-js'; import { url as local } from '@/config'; import { i18n } from '@/i18n'; import * as os from '@/os'; import { deviceKind } from '@/scripts/device-kind'; import MkButton from '@/components/MkButton.vue'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; import { versatileLang } from '@/scripts/intl-const'; import { defaultStore } from '@/store'; @@ -118,7 +126,9 @@ let player = $ref({ } as SummalyResult['player']); let playerEnabled = $ref(false); let tweetId = $ref<string | null>(null); -let tweetExpanded = $ref(props.detail); +let noteUrl = $ref<string | null>(null); +let note = $ref<misskey.entities.Note | null>(null); +let postExpanded = $ref(props.detail); const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; let tweetHeight = $ref(150); let unknownUrl = $ref(false); @@ -137,35 +147,60 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/ requestUrl.hash = ''; -window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) - .then(res => { - if (!res.ok) { - fetching = false; - unknownUrl = true; - return; - } +(async (): Promise<void> => { + const res = await window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`); + if (!res.ok) { + fetching = false; + unknownUrl = true; + return; + } - return res.json(); - }) - .then((info: SummalyResult) => { - if (info.url == null) { - fetching = false; - unknownUrl = true; - return; - } + const info = await res.json() as SummalyResult; + + fetching = false; + unknownUrl = false; + title = info.title; + description = info.description; + thumbnail = info.thumbnail; + icon = info.icon; + sitename = info.sitename; + player = info.player; + noteUrl = info.activityPub; + + if (postExpanded) { + await resolveNote(); + } +})(); + +async function resolveNote(): Promise<void> { + if (note) { + // Reuse the data + postExpanded = true; + return; + } + if (!noteUrl) { + // Note does not exist + return; + } + + try { + fetching = true; + const result = await os.api('ap/show', { uri: noteUrl }); + if (result.type === 'Note') { + note = result.object; + postExpanded = true; + } else { + postExpanded = false; + } + } finally { + // Prevent repeated resolving + noteUrl = null; fetching = false; - unknownUrl = false; - - title = info.title; - description = info.description; - thumbnail = info.thumbnail; - icon = info.icon; - sitename = info.sitename; - player = info.player; - }); + } +} -function adjustTweetHeight(message: any) { +function adjustTweetHeight(message: any): void { if (message.origin !== 'https://platform.twitter.com') return; const embed = message.data?.['twttr.embed']; if (embed?.method !== 'twttr.private.resize') return; diff --git a/packages/frontend/test/url-preview.test.ts b/packages/frontend/test/url-preview.test.ts index 1d43a628f2..8d3c322df6 100644 --- a/packages/frontend/test/url-preview.test.ts +++ b/packages/frontend/test/url-preview.test.ts @@ -3,17 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { describe, test, assert, afterEach } from 'vitest'; +import { describe, test, assert, afterEach, beforeAll, vi } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type { summaly } from 'summaly'; import { components } from '@/components'; import { directives } from '@/directives'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import type * as misskey from "misskey-js"; type SummalyResult = Awaited<ReturnType<typeof summaly>>; -describe('MkMediaImage', () => { +describe('MkUrlPreview', () => { const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => { if (!summary.player) { summary.player = { @@ -47,13 +48,18 @@ describe('MkMediaImage', () => { return result; }; - const renderAndOpenPreview = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => { + const renderAndOpenPreview = async (summary: Partial<SummalyResult>): Promise<RenderResult> => { const mkUrlPreview = await renderPreviewBy(summary); const buttons = mkUrlPreview.getAllByRole('button'); buttons[0].click(); // Wait for the click event to be fired await Promise.resolve(); + return mkUrlPreview; + }; + + const renderAndOpenPreviewInIFrame = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => { + const mkUrlPreview = await renderAndOpenPreview(summary); return mkUrlPreview.container.querySelector('iframe'); }; @@ -85,7 +91,7 @@ describe('MkMediaImage', () => { }); test('Having a player should setup the iframe', async () => { - const iframe = await renderAndOpenPreview({ + const iframe = await renderAndOpenPreviewInIFrame({ url: 'https://example.local', player: { url: 'https://example.local/player', @@ -103,7 +109,7 @@ describe('MkMediaImage', () => { }); test('Having a player with `allow` field should set permissions', async () => { - const iframe = await renderAndOpenPreview({ + const iframe = await renderAndOpenPreviewInIFrame({ url: 'https://example.local', player: { url: 'https://example.local/player', @@ -117,7 +123,7 @@ describe('MkMediaImage', () => { }); test('Having a player width should keep the fixed aspect ratio', async () => { - const iframe = await renderAndOpenPreview({ + const iframe = await renderAndOpenPreviewInIFrame({ url: 'https://example.local', player: { url: 'https://example.local/player', @@ -131,7 +137,7 @@ describe('MkMediaImage', () => { }); test('Having a player width should keep the fixed height', async () => { - const iframe = await renderAndOpenPreview({ + const iframe = await renderAndOpenPreviewInIFrame({ url: 'https://example.local', player: { url: 'https://example.local/player', @@ -143,4 +149,41 @@ describe('MkMediaImage', () => { assert.exists(iframe, 'iframe should exist'); assert.strictEqual(iframe?.parentElement?.style.paddingTop, '200px'); }); + + describe('ActivityPub notes', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test('Preview a note', async () => { + vi.mock('@/os', () => { + return { + api(endpoint: string): unknown { + if (endpoint === 'ap/show') { + return { + type: 'Note', + object: { + text: 'Mizuki', + createdAt: new Date().toISOString(), + user: {}, + files: [] as misskey.entities.DriveFile[], + } as misskey.entities.Note, + }; + } + throw new Error(`Unexpected api call ${endpoint}`); + }, + }; + }); + + const url = 'https://example.local'; + const renderResult = await renderAndOpenPreview({ + url, + description: 'Misskey', + activityPub: url, + }); + + assert.notExists(renderResult.queryByText('Misskey'), 'Original description should disappear'); + assert.exists(renderResult.queryByText('Mizuki'), 'ActivityPub fetch result should appear'); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a559e867e..3c0cd9ac15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -996,7 +996,7 @@ importers: specifier: github:misskey-dev/storybook-addon-misskey-theme version: github.com/misskey-dev/storybook-addon-misskey-theme/cf583db098365b2ccc81a82f63ca9c93bc32b640(@storybook/blocks@7.0.27)(@storybook/components@7.1.0)(@storybook/core-events@7.0.27)(@storybook/manager-api@7.0.27)(@storybook/preview-api@7.0.27)(@storybook/theming@7.0.27)(@storybook/types@7.0.27)(react-dom@18.2.0)(react@18.2.0) summaly: - specifier: github:misskey-dev/summaly + specifier: github:misskey-dev/summaly#d2d8db49943ccb201c1b1b283e9d0a630519fac7 version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7 vite-plugin-turbosnap: specifier: 1.0.2 -- GitLab