837a8e15d8
* refactor(frontend): shouldCollapsedを共通化 * refactor(frontend): config.js, worker-multi-dispatch.js, intl-const.jsを共通化 * fix(frontend-shared): fix type error * refactor(frontend): is-link.jsと、同一の振る舞いをする記述を共通化 * fix * fix lint * lint fixes
610 lines
14 KiB
Vue
610 lines
14 KiB
Vue
<!--
|
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
-->
|
|
|
|
<template>
|
|
<div
|
|
v-show="!isDeleted"
|
|
ref="rootEl"
|
|
:class="[$style.root]"
|
|
:tabindex="isDeleted ? '-1' : '0'"
|
|
>
|
|
<EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
|
|
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
|
|
<!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
|
|
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
|
|
<div v-if="isRenote" :class="$style.renote">
|
|
<div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
|
|
<EmAvatar :class="$style.renoteAvatar" :user="note.user" link/>
|
|
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
|
|
<I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText">
|
|
<template #user>
|
|
<EmA v-user-preview="true ? undefined : note.userId" :class="$style.renoteUserName" :to="userPage(note.user)">
|
|
<EmUserName :user="note.user"/>
|
|
</EmA>
|
|
</template>
|
|
</I18n>
|
|
<div :class="$style.renoteInfo">
|
|
<button ref="renoteTime" :class="$style.renoteTime" class="_button">
|
|
<i class="ti ti-dots" :class="$style.renoteMenu"></i>
|
|
<EmTime :time="note.createdAt"/>
|
|
</button>
|
|
<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>
|
|
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
|
|
</div>
|
|
</div>
|
|
<article :class="$style.article">
|
|
<div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
|
|
<EmAvatar :class="$style.avatar" :user="appearNote.user" link/>
|
|
<div :class="$style.main">
|
|
<EmNoteHeader :note="appearNote" :mini="true"/>
|
|
<div style="container-type: inline-size;">
|
|
<p v-if="appearNote.cw != null" :class="$style.cw">
|
|
<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
|
<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
|
|
</p>
|
|
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
|
|
<div :class="$style.text">
|
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
|
<EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
|
|
<EmMfm
|
|
v-if="appearNote.text"
|
|
:parsedNodes="parsed"
|
|
:text="appearNote.text"
|
|
:author="appearNote.user"
|
|
:nyaize="'respect'"
|
|
:emojiUrls="appearNote.emojis"
|
|
:enableEmojiMenu="!true"
|
|
:enableEmojiMenuReaction="true"
|
|
/>
|
|
</div>
|
|
<div v-if="appearNote.files && appearNote.files.length > 0">
|
|
<EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
|
|
</div>
|
|
<EmPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
|
|
<div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :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>
|
|
<EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA>
|
|
</div>
|
|
<EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16">
|
|
<template #more>
|
|
<EmA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA>
|
|
</template>
|
|
</EmReactionsViewer>
|
|
<footer :class="$style.footer">
|
|
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
|
|
<i class="ti ti-arrow-back-up"></i>
|
|
</a>
|
|
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
|
|
<i class="ti ti-repeat"></i>
|
|
</a>
|
|
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
|
|
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
|
<i v-else class="ti ti-plus"></i>
|
|
</a>
|
|
<a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
|
|
<i class="ti ti-dots"></i>
|
|
</a>
|
|
</footer>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { computed, inject, ref, shallowRef } from 'vue';
|
|
import * as mfm from 'mfm-js';
|
|
import * as Misskey from 'misskey-js';
|
|
import I18n from '@/components/I18n.vue';
|
|
import EmNoteSub from '@/components/EmNoteSub.vue';
|
|
import EmNoteHeader from '@/components/EmNoteHeader.vue';
|
|
import EmNoteSimple from '@/components/EmNoteSimple.vue';
|
|
import EmReactionsViewer from '@/components/EmReactionsViewer.vue';
|
|
import EmMediaList from '@/components/EmMediaList.vue';
|
|
import EmPoll from '@/components/EmPoll.vue';
|
|
import EmMfm from '@/components/EmMfm.js';
|
|
import EmA from '@/components/EmA.vue';
|
|
import EmAvatar from '@/components/EmAvatar.vue';
|
|
import EmUserName from '@/components/EmUserName.vue';
|
|
import EmTime from '@/components/EmTime.vue';
|
|
import { userPage } from '@/utils.js';
|
|
import { i18n } from '@/i18n.js';
|
|
import { shouldCollapsed } from '@@/js/collapsed.js';
|
|
import { url } from '@@/js/config.js';
|
|
|
|
function getAppearNote(note: Misskey.entities.Note) {
|
|
return Misskey.note.isPureRenote(note) ? note.renote : note;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
note: Misskey.entities.Note;
|
|
pinned?: boolean;
|
|
}>(), {
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
(ev: 'reaction', emoji: string): void;
|
|
(ev: 'removeReaction', emoji: string): void;
|
|
}>();
|
|
|
|
const inChannel = inject('inChannel', null);
|
|
|
|
const note = ref((props.note));
|
|
|
|
const isRenote = Misskey.note.isPureRenote(note.value);
|
|
|
|
const rootEl = shallowRef<HTMLElement>();
|
|
const renoteTime = shallowRef<HTMLElement>();
|
|
const appearNote = computed(() => getAppearNote(note.value));
|
|
const showContent = ref(false);
|
|
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
|
const isLong = shouldCollapsed(appearNote.value, []);
|
|
const collapsed = ref(appearNote.value.cw == null && isLong);
|
|
const isDeleted = ref(false);
|
|
</script>
|
|
|
|
<style lang="scss" module>
|
|
.root {
|
|
position: relative;
|
|
transition: box-shadow 0.1s ease;
|
|
font-size: 1.05em;
|
|
overflow: clip;
|
|
contain: content;
|
|
|
|
// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
|
|
// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
|
|
// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
|
|
// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
|
|
// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
|
|
//content-visibility: auto;
|
|
//contain-intrinsic-size: 0 128px;
|
|
|
|
&:focus-visible {
|
|
outline: none;
|
|
|
|
&::after {
|
|
content: "";
|
|
pointer-events: none;
|
|
display: block;
|
|
position: absolute;
|
|
z-index: 10;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
margin: auto;
|
|
width: calc(100% - 8px);
|
|
height: calc(100% - 8px);
|
|
border: dashed 2px var(--focus);
|
|
border-radius: var(--radius);
|
|
box-sizing: border-box;
|
|
}
|
|
}
|
|
|
|
.footer {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
&:hover > .article > .main > .footer > .footerButton {
|
|
opacity: 1;
|
|
}
|
|
|
|
&.showActionsOnlyHover {
|
|
.footer {
|
|
visibility: hidden;
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
padding: 0 4px;
|
|
margin-bottom: 0 !important;
|
|
background: var(--popup);
|
|
border-radius: 8px;
|
|
box-shadow: 0px 4px 32px var(--shadow);
|
|
}
|
|
|
|
.footerButton {
|
|
font-size: 90%;
|
|
|
|
&:not(:last-child) {
|
|
margin-right: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
&.showActionsOnlyHover:hover {
|
|
.footer {
|
|
visibility: visible;
|
|
}
|
|
}
|
|
}
|
|
|
|
.tip {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 16px 32px 8px 32px;
|
|
line-height: 24px;
|
|
font-size: 90%;
|
|
white-space: pre;
|
|
color: #d28a3f;
|
|
}
|
|
|
|
.tip + .article {
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.replyTo {
|
|
opacity: 0.7;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.renote {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 16px 32px 8px 32px;
|
|
line-height: 28px;
|
|
white-space: pre;
|
|
color: var(--renote);
|
|
|
|
& + .article {
|
|
padding-top: 8px;
|
|
}
|
|
|
|
> .colorBar {
|
|
height: calc(100% - 6px);
|
|
}
|
|
}
|
|
|
|
.renoteAvatar {
|
|
flex-shrink: 0;
|
|
display: inline-block;
|
|
width: 28px;
|
|
height: 28px;
|
|
margin: 0 8px 0 0;
|
|
}
|
|
|
|
.renoteText {
|
|
overflow: hidden;
|
|
flex-shrink: 1;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.renoteUserName {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.renoteInfo {
|
|
margin-left: auto;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.renoteTime {
|
|
flex-shrink: 0;
|
|
color: inherit;
|
|
}
|
|
|
|
.renoteMenu {
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.collapsedRenoteTarget {
|
|
display: flex;
|
|
align-items: center;
|
|
line-height: 28px;
|
|
white-space: pre;
|
|
padding: 0 32px 18px;
|
|
}
|
|
|
|
.collapsedRenoteTargetAvatar {
|
|
flex-shrink: 0;
|
|
display: inline-block;
|
|
width: 28px;
|
|
height: 28px;
|
|
margin: 0 8px 0 0;
|
|
}
|
|
|
|
.collapsedRenoteTargetText {
|
|
overflow: hidden;
|
|
flex-shrink: 1;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 90%;
|
|
opacity: 0.7;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
.article {
|
|
position: relative;
|
|
display: flex;
|
|
padding: 28px 32px;
|
|
}
|
|
|
|
.colorBar {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
width: 5px;
|
|
height: calc(100% - 16px);
|
|
border-radius: 999px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.avatar {
|
|
flex-shrink: 0;
|
|
display: block !important;
|
|
margin: 0 14px 0 0;
|
|
width: 58px;
|
|
height: 58px;
|
|
position: sticky !important;
|
|
top: calc(22px + var(--stickyTop, 0px));
|
|
left: 0;
|
|
}
|
|
|
|
.main {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.cw {
|
|
cursor: default;
|
|
display: block;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.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), color(from var(--panel) srgb r g b / 0));
|
|
|
|
&: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%);
|
|
}
|
|
|
|
.text {
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.replyIcon {
|
|
color: var(--accent);
|
|
margin-right: 0.5em;
|
|
}
|
|
|
|
.translation {
|
|
border: solid 0.5px var(--divider);
|
|
border-radius: var(--radius);
|
|
padding: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.urlPreview {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.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%;
|
|
}
|
|
|
|
.footer {
|
|
margin-bottom: -14px;
|
|
}
|
|
|
|
.footerButton {
|
|
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;
|
|
}
|
|
|
|
.footerButtonCount {
|
|
display: inline;
|
|
margin: 0 0 0 8px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
@container (max-width: 580px) {
|
|
.root {
|
|
font-size: 0.95em;
|
|
}
|
|
|
|
.renote {
|
|
padding: 12px 26px 0 26px;
|
|
}
|
|
|
|
.article {
|
|
padding: 24px 26px;
|
|
}
|
|
|
|
.avatar {
|
|
width: 50px;
|
|
height: 50px;
|
|
}
|
|
}
|
|
|
|
@container (max-width: 500px) {
|
|
.root {
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.renote {
|
|
padding: 10px 22px 0 22px;
|
|
}
|
|
|
|
.article {
|
|
padding: 20px 22px;
|
|
}
|
|
|
|
.footer {
|
|
margin-bottom: -8px;
|
|
}
|
|
}
|
|
|
|
@container (max-width: 480px) {
|
|
.renote {
|
|
padding: 8px 16px 0 16px;
|
|
}
|
|
|
|
.tip {
|
|
padding: 8px 16px 0 16px;
|
|
}
|
|
|
|
.collapsedRenoteTarget {
|
|
padding: 0 16px 9px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.article {
|
|
padding: 14px 16px;
|
|
}
|
|
}
|
|
|
|
@container (max-width: 450px) {
|
|
.avatar {
|
|
margin: 0 10px 0 0;
|
|
width: 46px;
|
|
height: 46px;
|
|
top: calc(14px + var(--stickyTop, 0px));
|
|
}
|
|
}
|
|
|
|
@container (max-width: 400px) {
|
|
.root:not(.showActionsOnlyHover) {
|
|
.footerButton {
|
|
&:not(:last-child) {
|
|
margin-right: 18px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@container (max-width: 350px) {
|
|
.root:not(.showActionsOnlyHover) {
|
|
.footerButton {
|
|
&:not(:last-child) {
|
|
margin-right: 12px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.colorBar {
|
|
top: 6px;
|
|
left: 6px;
|
|
width: 4px;
|
|
height: calc(100% - 12px);
|
|
}
|
|
}
|
|
|
|
@container (max-width: 300px) {
|
|
.avatar {
|
|
width: 44px;
|
|
height: 44px;
|
|
}
|
|
|
|
.root:not(.showActionsOnlyHover) {
|
|
.footerButton {
|
|
&:not(:last-child) {
|
|
margin-right: 8px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@container (max-width: 250px) {
|
|
.quoteNote {
|
|
padding: 12px;
|
|
}
|
|
}
|
|
|
|
.reactionOmitted {
|
|
display: inline-block;
|
|
margin-left: 8px;
|
|
opacity: .8;
|
|
font-size: 95%;
|
|
}
|
|
</style>
|