embedドメインをまとめた

This commit is contained in:
syuilo
2024-08-20 16:08:08 +09:00
parent 62f5aafa06
commit 711eb8086a
9 changed files with 2 additions and 2 deletions

View File

@@ -0,0 +1,493 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
v-show="!isDeleted"
ref="rootEl"
:class="$style.root"
>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="isRenote" :class="$style.renote">
<MkAvatar :class="$style.renoteAvatar" :user="note.user" link/>
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
<span :class="$style.renoteText">
<I18n :src="i18n.ts.renotedBy" tag="span">
<template #user>
<MkA :class="$style.renoteName" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
</template>
</I18n>
</span>
<div :class="$style.renoteInfo">
<div class="$style.renoteTime">
<MkTime :time="note.createdAt"/>
</div>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
<article :class="$style.note">
<header :class="$style.noteHeader">
<MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link/>
<div :class="$style.noteHeaderBody">
<div :class="$style.noteHeaderBodyUpper">
<div style="min-width: 0;">
<div class="_nowrap">
<MkA :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
<MkUserName :nowrap="true" :user="appearNote.user"/>
</MkA>
<span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
</div>
<div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div>
</div>
<div :class="$style.noteHeaderInfo">
<a :href="url" :class="$style.noteHeaderInstanceIconLink" target="_blank" rel="noopener noreferrer">
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.noteHeaderInstanceIcon"/>
</a>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
</div>
</header>
<div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'respect'"
:emojiUrls="appearNote.emojis"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
</button>
<button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
<span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
</button>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer>
<div :class="$style.noteFooterInfo">
<span v-if="appearNote.visibility !== 'public'" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="appearNote.localOnly" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :maxNumber="16" :note="appearNote">
<template #more>
<MkA :to="`/notes/${appearNote.id}`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
</MkReactionsViewer>
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
<i class="ti ti-arrow-back-up"></i>
</a>
<a v-if="canRenote" :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</a>
<a v-else :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button" disabled>
<i class="ti ti-ban"></i>
</a>
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</a>
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
<i class="ti ti-dots"></i>
</a>
</footer>
</article>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, ref } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/scripts/clone.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { instance } from '@/instance.js';
import { url } from '@/config.js';
const props = defineProps<{
note: Misskey.entities.Note;
}>();
const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note));
const isRenote = (
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const showContent = ref(false);
const isDeleted = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
const isLong = shouldCollapsed(appearNote.value, urls ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
</script>
<style lang="scss" module>
.root {
position: relative;
transition: box-shadow 0.1s ease;
overflow: clip;
contain: content;
}
.replyTo {
opacity: 0.7;
padding-bottom: 0;
}
.renote {
display: flex;
align-items: center;
padding: 16px 32px 8px 32px;
line-height: 28px;
white-space: pre;
color: var(--renote);
}
.renoteAvatar {
flex-shrink: 0;
display: inline-block;
width: 28px;
height: 28px;
margin: 0 8px 0 0;
border-radius: 6px;
}
.renoteText {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.renoteName {
font-weight: bold;
}
.renoteInfo {
margin-left: auto;
font-size: 0.9em;
}
.renoteTime {
flex-shrink: 0;
color: inherit;
}
.renote + .note {
padding-top: 8px;
}
.note {
padding: 24px 32px 16px;
font-size: 1.2em;
&:hover > .main > .footer > .button {
opacity: 1;
}
}
.noteHeader {
display: flex;
position: relative;
margin-bottom: 16px;
align-items: center;
}
.noteHeaderAvatar {
display: block;
flex-shrink: 0;
width: 50px;
height: 50px;
}
.noteHeaderBody {
flex: 1;
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
padding-left: 16px;
font-size: 0.95em;
}
.noteHeaderBodyUpper {
display: flex;
min-width: 0;
}
.noteHeaderName {
font-weight: bold;
line-height: 1.3;
}
.isBot {
display: inline-block;
margin: 0 0.5em;
padding: 4px 6px;
font-size: 80%;
line-height: 1;
border: solid 0.5px var(--divider);
border-radius: 4px;
}
.noteHeaderInfo {
margin-left: auto;
display: flex;
gap: 0.5em;
align-items: center;
}
.noteHeaderInstanceIconLink {
display: inline-block;
margin-left: 4px;
}
.noteHeaderInstanceIcon {
width: 32px;
height: 32px;
border-radius: 4px;
}
.noteHeaderUsername {
margin-bottom: 2px;
line-height: 1.3;
word-wrap: anywhere;
}
.noteContent {
container-type: inline-size;
overflow-wrap: break-word;
}
.cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
}
.noteReplyTarget {
color: var(--accent);
margin-right: 0.5em;
}
.rn {
margin-left: 4px;
font-style: oblique;
color: var(--renote);
}
.reactionOmitted {
display: inline-block;
margin-left: 8px;
opacity: .8;
font-size: 95%;
}
.poll {
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%;
}
.showLess {
width: 100%;
margin-top: 14px;
position: sticky;
bottom: calc(var(--stickyBottom, 0px) + 14px);
}
.showLessLabel {
display: inline-block;
background: var(--popup);
padding: 6px 10px;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
}
.contentCollapsed {
position: relative;
max-height: 9em;
overflow: clip;
}
.collapsed {
display: block;
position: absolute;
bottom: 0;
left: 0;
z-index: 2;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
&:hover > .collapsedLabel {
background: var(--panelHighlight);
}
}
.collapsedLabel {
display: inline-block;
background: var(--panel);
padding: 6px 10px;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
}
.noteFooterInfo {
margin: 16px 0;
opacity: 0.7;
font-size: 0.9em;
}
.noteFooterButton {
margin: 0;
padding: 8px;
opacity: 0.7;
&:not(:last-child) {
margin-right: 28px;
}
&:hover {
color: var(--fgHighlighted);
}
}
.footerButtonLink:hover,
.footerButtonLink:focus,
.footerButtonLink:active {
text-decoration: none;
}
.noteFooterButtonCount {
display: inline;
margin: 0 0 0 8px;
opacity: 0.7;
&.reacted {
color: var(--accent);
}
}
@container (max-width: 500px) {
.root {
font-size: 0.9em;
}
}
@container (max-width: 450px) {
.renote {
padding: 8px 16px 0 16px;
}
.note {
padding: 16px;
}
.noteHeaderAvatar {
width: 50px;
height: 50px;
}
}
@container (max-width: 350px) {
.noteFooterButton {
&:not(:last-child) {
margin-right: 18px;
}
}
}
@container (max-width: 300px) {
.root {
font-size: 0.825em;
}
.noteHeaderAvatar {
width: 50px;
height: 50px;
}
.noteFooterButton {
&:not(:last-child) {
margin-right: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,39 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.timelineRoot">
<div v-if="showHeader" :class="$style.header"><slot name="header"></slot></div>
<div :class="$style.body"><slot name="body"></slot></div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
showHeader?: boolean;
}>(), {
showHeader: true,
});
</script>
<style module lang="scss">
.timelineRoot {
background-color: var(--panel);
height: 100%;
max-height: var(--embedMaxHeight, none);
display: flex;
flex-direction: column;
}
.header {
flex-shrink: 0;
border-bottom: 1px solid var(--divider);
}
.body {
flex-grow: 1;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,154 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkLoading v-if="loading"/>
<XEmbedTimelineUI v-else-if="clip" :showHeader="embedParams.header">
<template #header>
<div :class="$style.clipHeader">
<div :class="$style.headerClipIconRoot">
<i class="ti ti-paperclip"></i>
</div>
<div :class="$style.headerTitle" @click="top">
<div class="_nowrap"><a :href="`/clips/${clip.id}`" target="_blank" rel="noopener">{{ clip.name }}</a></div>
<div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
</div>
<a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
<img
:class="$style.instanceIcon"
:src="instance.iconUrl || '/favicon.ico'"
/>
</a>
</div>
</template>
<template #body>
<MkNotes
ref="notesEl"
:pagination="pagination"
:disableAutoLoad="!embedParams.autoload"
:noGap="true"
:ad="false"
/>
</template>
</XEmbedTimelineUI>
<XNotFound v-else/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, inject, onActivated } from 'vue';
import * as Misskey from 'misskey-js';
import MkNotes from '@/components/MkNotes.vue';
import XNotFound from '@/pages/not-found.vue';
import XEmbedTimelineUI from '@/pages/embed/_timeline_ui_.vue';
import type { Paging } from '@/components/MkPagination.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { url, instanceName } from '@/config.js';
import { scrollToTop } from '@/scripts/scroll.js';
import { isLink } from '@/scripts/is-link.js';
import { useRouter } from '@/router/supplier.js';
import { defaultEmbedParams } from '@/scripts/embed-page.js';
import type { ParsedEmbedParams } from '@/scripts/embed-page.js';
const props = defineProps<{
clipId: string;
}>();
function redirectIfNotEmbedPage() {
const inEmbedPage = inject<boolean>('EMBED_PAGE', false);
if (!inEmbedPage) {
const router = useRouter();
router.replace(`/clips/${props.clipId}`);
}
}
redirectIfNotEmbedPage();
onActivated(redirectIfNotEmbedPage);
const embedParams = inject<ParsedEmbedParams>('embedParams', defaultEmbedParams);
const clip = ref<Misskey.entities.Clip | null>(null);
const pagination = computed(() => ({
endpoint: 'clips/notes',
params: {
clipId: props.clipId,
},
} as Paging));
const loading = ref(true);
const notesEl = shallowRef<InstanceType<typeof MkNotes> | null>(null);
function top(ev: MouseEvent) {
const target = ev.target as HTMLElement | null;
if (target && isLink(target)) return;
if (notesEl.value) {
scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
}
}
misskeyApi('clips/show', {
clipId: props.clipId,
}).then(res => {
clip.value = res;
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
</script>
<style lang="scss" module>
.clipHeader {
padding: 8px 16px;
display: flex;
min-width: 0;
align-items: center;
gap: var(--margin);
overflow: hidden;
.headerClipIconRoot {
flex-shrink: 0;
width: 32px;
height: 32px;
line-height: 32px;
font-size: 14px;
text-align: center;
background-color: var(--accentedBg);
color: var(--accent);
border-radius: 50%;
}
.headerTitle {
flex-grow: 1;
font-weight: 700;
line-height: 1.1;
min-width: 0;
.sub {
font-size: 0.8em;
font-weight: 400;
opacity: 0.7;
}
}
.instanceIconLink {
flex-shrink: 0;
display: block;
margin-left: auto;
height: 24px;
}
.instanceIcon {
height: 24px;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,62 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.noteEmbedRoot">
<MkLoading v-if="loading"/>
<MkNoteEmbed v-else-if="note" :note="note"/>
<XNotFound v-else/>
</div>
</template>
<script setup lang="ts">
import { ref, provide, inject, onActivated } from 'vue';
import * as Misskey from 'misskey-js';
import MkNoteEmbed from '@/components/MkNoteEmbed.vue';
import XNotFound from '@/pages/not-found.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router/supplier.js';
const props = defineProps<{
noteId: string;
}>();
function redirectIfNotEmbedPage() {
const inEmbedPage = inject<boolean>('EMBED_PAGE', false);
if (!inEmbedPage) {
const router = useRouter();
router.replace(`/notes/${props.noteId}`);
}
}
redirectIfNotEmbedPage();
onActivated(redirectIfNotEmbedPage);
provide('EMBED_ORIGINAL_ENTITY_URL', `/notes/${props.noteId}`);
const note = ref<Misskey.entities.Note | null>(null);
const loading = ref(true);
misskeyApi('notes/show', {
noteId: props.noteId,
}).then(res => {
// リモートのノートは埋め込ませない
if (res.url == null && res.uri == null) {
note.value = res;
}
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
</script>
<style lang="scss" module>
.noteEmbedRoot {
background-color: var(--panel);
}
</style>

View File

@@ -0,0 +1,139 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<XEmbedTimelineUI v-if="tag" :showHeader="embedParams.header">
<template #header>
<div :class="$style.clipHeader">
<div :class="$style.headerClipIconRoot">
<i class="ti ti-hash"></i>
</div>
<div :class="$style.headerTitle" @click="top">
<div class="_nowrap"><a :href="`/tags/${tag}`" target="_blank" rel="noopener">#{{ tag }}</a></div>
<div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
</div>
<a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
<img
:class="$style.instanceIcon"
:src="instance.iconUrl || '/favicon.ico'"
/>
</a>
</div>
</template>
<template #body>
<MkNotes
ref="notesEl"
:pagination="pagination"
:disableAutoLoad="!embedParams.autoload"
:noGap="true"
:ad="false"
/>
</template>
</XEmbedTimelineUI>
<XNotFound v-else/>
</div>
</template>
<script setup lang="ts">
import { computed, shallowRef, inject, onActivated } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import XNotFound from '@/pages/not-found.vue';
import XEmbedTimelineUI from '@/pages/embed/_timeline_ui_.vue';
import type { Paging } from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { url, instanceName } from '@/config.js';
import { scrollToTop } from '@/scripts/scroll.js';
import { isLink } from '@/scripts/is-link.js';
import { useRouter } from '@/router/supplier.js';
import { defaultEmbedParams } from '@/scripts/embed-page.js';
import type { ParsedEmbedParams } from '@/scripts/embed-page.js';
const props = defineProps<{
tag: string;
}>();
function redirectIfNotEmbedPage() {
const inEmbedPage = inject<boolean>('EMBED_PAGE', false);
if (!inEmbedPage) {
const router = useRouter();
router.replace(`/tags/${props.tag}`);
}
}
redirectIfNotEmbedPage();
onActivated(redirectIfNotEmbedPage);
const embedParams = inject<ParsedEmbedParams>('embedParams', defaultEmbedParams);
const pagination = computed(() => ({
endpoint: 'notes/search-by-tag',
params: {
tag: props.tag,
},
} as Paging));
const notesEl = shallowRef<InstanceType<typeof MkNotes> | null>(null);
function top(ev: MouseEvent) {
const target = ev.target as HTMLElement | null;
if (target && isLink(target)) return;
if (notesEl.value) {
scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
}
}
</script>
<style lang="scss" module>
.clipHeader {
padding: 8px 16px;
display: flex;
min-width: 0;
align-items: center;
gap: var(--margin);
overflow: hidden;
.headerClipIconRoot {
flex-shrink: 0;
width: 32px;
height: 32px;
line-height: 32px;
font-size: 14px;
text-align: center;
background-color: var(--accentedBg);
color: var(--accent);
border-radius: 50%;
}
.headerTitle {
flex-grow: 1;
font-weight: 700;
line-height: 1.1;
min-width: 0;
.sub {
font-size: 0.8em;
font-weight: 400;
opacity: 0.7;
}
}
.instanceIconLink {
flex-shrink: 0;
display: block;
margin-left: auto;
height: 24px;
}
.instanceIcon {
height: 24px;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkLoading v-if="loading"/>
<XEmbedTimelineUI v-else-if="user" :showHeader="embedParams.header">
<template #header>
<div :class="$style.userHeader">
<a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink">
<MkAvatar :class="$style.avatar" :user="user"/>
</a>
<div :class="$style.headerTitle" @click="top">
<I18n :src="i18n.ts.noteOf" tag="div" class="_nowrap">
<template #user>
<a v-if="user != null" :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer">
<MkUserName :user="user"/>
</a>
<span v-else>{{ i18n.ts.user }}</span>
</template>
</I18n>
<div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
</div>
<a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
<img
:class="$style.instanceIcon"
:src="instance.iconUrl || '/favicon.ico'"
/>
</a>
</div>
</template>
<template #body>
<MkNotes
ref="notesEl"
:pagination="pagination"
:disableAutoLoad="!embedParams.autoload"
:noGap="true"
:ad="false"
/>
</template>
</XEmbedTimelineUI>
<XNotFound v-else/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, inject, onActivated } from 'vue';
import * as Misskey from 'misskey-js';
import MkNotes from '@/components/MkNotes.vue';
import XNotFound from '@/pages/not-found.vue';
import XEmbedTimelineUI from '@/pages/embed/_timeline_ui_.vue';
import type { Paging } from '@/components/MkPagination.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { url, instanceName } from '@/config.js';
import { scrollToTop } from '@/scripts/scroll.js';
import { isLink } from '@/scripts/is-link.js';
import { useRouter } from '@/router/supplier.js';
import { defaultEmbedParams } from '@/scripts/embed-page.js';
import type { ParsedEmbedParams } from '@/scripts/embed-page.js';
const props = defineProps<{
username: string;
}>();
function redirectIfNotEmbedPage() {
const inEmbedPage = inject<boolean>('EMBED_PAGE', false);
if (!inEmbedPage) {
const router = useRouter();
router.replace(`/@${props.username}`);
}
}
redirectIfNotEmbedPage();
onActivated(redirectIfNotEmbedPage);
const embedParams = inject<ParsedEmbedParams>('embedParams', defaultEmbedParams);
const user = ref<Misskey.entities.UserLite | null>(null);
const pagination = computed(() => ({
endpoint: 'users/notes',
params: {
userId: user.value?.id,
},
} as Paging));
const loading = ref(true);
const notesEl = shallowRef<InstanceType<typeof MkNotes> | null>(null);
function top(ev: MouseEvent) {
const target = ev.target as HTMLElement | null;
if (target && isLink(target)) return;
if (notesEl.value) {
scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
}
}
misskeyApi('users/show', {
username: props.username,
}).then(res => {
user.value = res;
loading.value = false;
}).catch(err => {
console.error(err);
loading.value = false;
});
</script>
<style lang="scss" module>
.userHeader {
padding: 8px 16px;
display: flex;
min-width: 0;
align-items: center;
gap: var(--margin);
overflow: hidden;
.avatarLink {
display: block;
}
.avatar {
display: inline-block;
width: 32px;
height: 32px;
}
.headerTitle {
flex-grow: 1;
font-weight: 700;
line-height: 1.1;
min-width: 0;
.sub {
font-size: 0.8em;
font-weight: 400;
opacity: 0.7;
}
}
.instanceIconLink {
flex-shrink: 0;
display: block;
margin-left: auto;
height: 24px;
}
.instanceIcon {
height: 24px;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { IRouter, RouteDef } from '@/nirax.js';
import { Router } from '@/nirax.js';
import { page } from '@/router/definition.js';
import { $i } from '@/account.js';
const routes: RouteDef[] = [{
path: '/embed/notes/:noteId',
component: page(() => import('@/pages/embed/note.vue')),
}, {
path: '/embed/user-timeline/@:username',
component: page(() => import('@/pages/embed/user-timeline.vue')),
}, {
path: '/embed/clips/:clipId',
component: page(() => import('@/pages/embed/clip.vue')),
}, {
path: '/embed/tags/:tag',
component: page(() => import('@/pages/embed/tag.vue')),
}, {
path: '/:(*)',
component: page(() => import('@/pages/not-found.vue')),
}];
export function createEmbedRouter(path: string): IRouter {
return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue')));
}

View File

@@ -0,0 +1,19 @@
@charset "utf-8";
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
html.embed {
background-color: transparent;
color-scheme: light dark;
overflow: hidden;
}
html.embed,
html.embed body,
html.embed #misskey_app {
height: 100%;
}