feat(frontend/MkUrlPreview): support expanding ActivityPub notes
This commit is contained in:
parent
ec229dbd3b
commit
47986eb6f5
|
@ -589,6 +589,7 @@ export interface Locale {
|
||||||
"enablePlayer": string;
|
"enablePlayer": string;
|
||||||
"disablePlayer": string;
|
"disablePlayer": string;
|
||||||
"expandTweet": string;
|
"expandTweet": string;
|
||||||
|
"expandNote": string;
|
||||||
"themeEditor": string;
|
"themeEditor": string;
|
||||||
"description": string;
|
"description": string;
|
||||||
"describeFile": string;
|
"describeFile": string;
|
||||||
|
|
|
@ -586,6 +586,7 @@ useCw: "内容を隠す"
|
||||||
enablePlayer: "プレイヤーを開く"
|
enablePlayer: "プレイヤーを開く"
|
||||||
disablePlayer: "プレイヤーを閉じる"
|
disablePlayer: "プレイヤーを閉じる"
|
||||||
expandTweet: "ツイートを展開する"
|
expandTweet: "ツイートを展開する"
|
||||||
|
expandNote: "ノートを展開する"
|
||||||
themeEditor: "テーマエディター"
|
themeEditor: "テーマエディター"
|
||||||
description: "説明"
|
description: "説明"
|
||||||
describeFile: "キャプションを付ける"
|
describeFile: "キャプションを付ける"
|
||||||
|
|
|
@ -134,7 +134,7 @@
|
||||||
"start-server-and-test": "2.0.0",
|
"start-server-and-test": "2.0.0",
|
||||||
"storybook": "7.0.27",
|
"storybook": "7.0.27",
|
||||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
"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",
|
"vite-plugin-turbosnap": "1.0.2",
|
||||||
"vitest": "0.33.0",
|
"vitest": "0.33.0",
|
||||||
"vitest-fetch-mock": "0.2.2",
|
"vitest-fetch-mock": "0.2.2",
|
||||||
|
|
|
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/>
|
<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"/>
|
<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">
|
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
|
||||||
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
|
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -758,17 +758,6 @@ function showReactions(): void {
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote {
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quoteNote {
|
|
||||||
padding: 16px;
|
|
||||||
border: dashed 1px var(--renote);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel {
|
.channel {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
|
@ -905,12 +894,6 @@ function showReactions(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 250px) {
|
|
||||||
.quoteNote {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.root">
|
<div :class="[$style.root, quoted ? $style.quoted : null]">
|
||||||
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
|
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
|
||||||
<div :class="$style.main">
|
<div :class="$style.main">
|
||||||
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
|
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
|
||||||
|
@ -32,6 +32,7 @@ import { $i } from '@/account';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
note: misskey.entities.Note;
|
note: misskey.entities.Note;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
|
quoted?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showContent = $ref(false);
|
const showContent = $ref(false);
|
||||||
|
@ -80,12 +81,24 @@ const showContent = $ref(false);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: dashed 1px var(--renote);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
@container (min-width: 250px) {
|
@container (min-width: 250px) {
|
||||||
.avatar {
|
.avatar {
|
||||||
margin: 0 10px 0 0;
|
margin: 0 10px 0 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (min-width: 350px) {
|
@container (min-width: 350px) {
|
||||||
|
|
|
@ -26,13 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="tweetId && tweetExpanded">
|
<template v-else-if="postExpanded">
|
||||||
<div ref="twitter">
|
<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>
|
<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>
|
</div>
|
||||||
|
<MkNoteSimple v-else-if="note" :note="note" :quoted="true"/>
|
||||||
<div :class="$style.action">
|
<div :class="$style.action">
|
||||||
<MkButton :small="true" inline @click="tweetExpanded = false">
|
<MkButton :small="true" inline @click="postExpanded = false">
|
||||||
<i class="ti ti-x"></i> {{ i18n.ts.close }}
|
<i v-if="tweetId" class="ti ti-x"></i> {{ i18n.ts.close }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -59,10 +60,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</component>
|
</component>
|
||||||
<template v-if="showActions">
|
<template v-if="showActions">
|
||||||
<div v-if="tweetId" :class="$style.action">
|
<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 }}
|
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</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">
|
<div v-if="!playerEnabled && player.url" :class="$style.action">
|
||||||
<MkButton :small="true" inline @click="playerEnabled = true">
|
<MkButton :small="true" inline @click="playerEnabled = true">
|
||||||
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
|
<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>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, onUnmounted } from 'vue';
|
import { defineAsyncComponent, onUnmounted } from 'vue';
|
||||||
import type { summaly } from 'summaly';
|
import type { summaly } from 'summaly';
|
||||||
|
import type * as misskey from 'misskey-js';
|
||||||
import { url as local } from '@/config';
|
import { url as local } from '@/config';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { deviceKind } from '@/scripts/device-kind';
|
import { deviceKind } from '@/scripts/device-kind';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||||
import { versatileLang } from '@/scripts/intl-const';
|
import { versatileLang } from '@/scripts/intl-const';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
|
|
||||||
|
@ -118,7 +126,9 @@ let player = $ref({
|
||||||
} as SummalyResult['player']);
|
} as SummalyResult['player']);
|
||||||
let playerEnabled = $ref(false);
|
let playerEnabled = $ref(false);
|
||||||
let tweetId = $ref<string | null>(null);
|
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/, '')}`;
|
const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
|
||||||
let tweetHeight = $ref(150);
|
let tweetHeight = $ref(150);
|
||||||
let unknownUrl = $ref(false);
|
let unknownUrl = $ref(false);
|
||||||
|
@ -137,22 +147,15 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
|
||||||
|
|
||||||
requestUrl.hash = '';
|
requestUrl.hash = '';
|
||||||
|
|
||||||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
|
(async (): Promise<void> => {
|
||||||
.then(res => {
|
const res = await window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
fetching = false;
|
fetching = false;
|
||||||
unknownUrl = true;
|
unknownUrl = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
const info = await res.json() as SummalyResult;
|
||||||
})
|
|
||||||
.then((info: SummalyResult) => {
|
|
||||||
if (info.url == null) {
|
|
||||||
fetching = false;
|
|
||||||
unknownUrl = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetching = false;
|
fetching = false;
|
||||||
unknownUrl = false;
|
unknownUrl = false;
|
||||||
|
@ -163,9 +166,41 @@ window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLa
|
||||||
icon = info.icon;
|
icon = info.icon;
|
||||||
sitename = info.sitename;
|
sitename = info.sitename;
|
||||||
player = info.player;
|
player = info.player;
|
||||||
});
|
noteUrl = info.activityPub;
|
||||||
|
|
||||||
function adjustTweetHeight(message: any) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustTweetHeight(message: any): void {
|
||||||
if (message.origin !== 'https://platform.twitter.com') return;
|
if (message.origin !== 'https://platform.twitter.com') return;
|
||||||
const embed = message.data?.['twttr.embed'];
|
const embed = message.data?.['twttr.embed'];
|
||||||
if (embed?.method !== 'twttr.private.resize') return;
|
if (embed?.method !== 'twttr.private.resize') return;
|
||||||
|
|
|
@ -3,17 +3,18 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { render, cleanup, type RenderResult } from '@testing-library/vue';
|
||||||
import './init';
|
import './init';
|
||||||
import type { summaly } from 'summaly';
|
import type { summaly } from 'summaly';
|
||||||
import { components } from '@/components';
|
import { components } from '@/components';
|
||||||
import { directives } from '@/directives';
|
import { directives } from '@/directives';
|
||||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||||
|
import type * as misskey from "misskey-js";
|
||||||
|
|
||||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||||
|
|
||||||
describe('MkMediaImage', () => {
|
describe('MkUrlPreview', () => {
|
||||||
const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
|
const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
|
||||||
if (!summary.player) {
|
if (!summary.player) {
|
||||||
summary.player = {
|
summary.player = {
|
||||||
|
@ -47,13 +48,18 @@ describe('MkMediaImage', () => {
|
||||||
return result;
|
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 mkUrlPreview = await renderPreviewBy(summary);
|
||||||
const buttons = mkUrlPreview.getAllByRole('button');
|
const buttons = mkUrlPreview.getAllByRole('button');
|
||||||
buttons[0].click();
|
buttons[0].click();
|
||||||
// Wait for the click event to be fired
|
// Wait for the click event to be fired
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
|
return mkUrlPreview;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAndOpenPreviewInIFrame = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => {
|
||||||
|
const mkUrlPreview = await renderAndOpenPreview(summary);
|
||||||
return mkUrlPreview.container.querySelector('iframe');
|
return mkUrlPreview.container.querySelector('iframe');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -85,7 +91,7 @@ describe('MkMediaImage', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Having a player should setup the iframe', async () => {
|
test('Having a player should setup the iframe', async () => {
|
||||||
const iframe = await renderAndOpenPreview({
|
const iframe = await renderAndOpenPreviewInIFrame({
|
||||||
url: 'https://example.local',
|
url: 'https://example.local',
|
||||||
player: {
|
player: {
|
||||||
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 () => {
|
test('Having a player with `allow` field should set permissions', async () => {
|
||||||
const iframe = await renderAndOpenPreview({
|
const iframe = await renderAndOpenPreviewInIFrame({
|
||||||
url: 'https://example.local',
|
url: 'https://example.local',
|
||||||
player: {
|
player: {
|
||||||
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 () => {
|
test('Having a player width should keep the fixed aspect ratio', async () => {
|
||||||
const iframe = await renderAndOpenPreview({
|
const iframe = await renderAndOpenPreviewInIFrame({
|
||||||
url: 'https://example.local',
|
url: 'https://example.local',
|
||||||
player: {
|
player: {
|
||||||
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 () => {
|
test('Having a player width should keep the fixed height', async () => {
|
||||||
const iframe = await renderAndOpenPreview({
|
const iframe = await renderAndOpenPreviewInIFrame({
|
||||||
url: 'https://example.local',
|
url: 'https://example.local',
|
||||||
player: {
|
player: {
|
||||||
url: 'https://example.local/player',
|
url: 'https://example.local/player',
|
||||||
|
@ -143,4 +149,41 @@ describe('MkMediaImage', () => {
|
||||||
assert.exists(iframe, 'iframe should exist');
|
assert.exists(iframe, 'iframe should exist');
|
||||||
assert.strictEqual(iframe?.parentElement?.style.paddingTop, '200px');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -996,7 +996,7 @@ importers:
|
||||||
specifier: github:misskey-dev/storybook-addon-misskey-theme
|
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)
|
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:
|
summaly:
|
||||||
specifier: github:misskey-dev/summaly
|
specifier: github:misskey-dev/summaly#d2d8db49943ccb201c1b1b283e9d0a630519fac7
|
||||||
version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7
|
version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7
|
||||||
vite-plugin-turbosnap:
|
vite-plugin-turbosnap:
|
||||||
specifier: 1.0.2
|
specifier: 1.0.2
|
||||||
|
|
Loading…
Reference in New Issue