Whalebird-desktop-client-ma.../src/renderer/components/organisms/Notification/StatusReaction.vue

508 lines
15 KiB
Vue

<template>
<div ref="notificationRef" class="status" tabIndex="0" role="article" :aria-label="reactionType" @click="$emit('select')">
<div v-show="filtered" class="filtered">Filtered</div>
<div v-show="!filtered" class="status-reaction">
<div class="action">
<div :class="`action-mark ${reactionClass}`">
<template v-if="reactionIcon === 'unknown'">
{{ message.emoji }}
</template>
<template v-else>
<font-awesome-icon :icon="reactionIcon" size="sm" />
</template>
</div>
<div class="action-detail">
<span class="bold" @click="openUser(message.account)">
<bdi
v-html="
$t(reactionMessage, {
username: username(message.account),
interpolation: { escapeValue: false }
})
"
></bdi>
</span>
</div>
<div class="action-icon" role="presentation">
<FailoverImg :src="message.account.avatar" :alt="`Avatar of ${message.account.username}`" />
</div>
</div>
<div class="clearfix"></div>
<div class="target" v-on:dblclick="openDetail(status)">
<div class="icon" @click="openUser(status.account)">
<FailoverImg :src="status.account.avatar" :alt="`Avatar of ${status.account.username}`" role="presentation" />
</div>
<div class="detail">
<div class="toot-header">
<div class="user" @click="openUser(status.account)">
<span class="display-name"><bdi v-html="username(status.account)"></bdi></span>
</div>
<div class="timestamp">
<time :datetime="message.created_at" :title="readableTimestamp">
{{ timestamp }}
</time>
</div>
<div class="clearfix"></div>
</div>
<div class="content-wrapper">
<div class="spoiler" v-show="spoilered">
<span v-html="spoilerText"></span>
<el-button v-if="!isShowContent" plain type="primary" size="default" class="spoil-button" @click="showContent = true">
{{ $t('cards.toot.show_more') }}
</el-button>
<el-button v-else plain type="primary" size="default" class="spoil-button" @click="showContent = false">
{{ $t('cards.toot.hide') }}
</el-button>
</div>
<div class="content" v-show="isShowContent" v-html="statusText" @click.capture.prevent="tootClick"></div>
</div>
<div class="attachments">
<el-button v-show="sensitive && !isShowAttachments" class="show-sensitive" type="info" @click="showAttachments = true">
{{ $t('cards.toot.sensitive') }}
</el-button>
<div v-show="isShowAttachments">
<el-button
v-show="sensitive && isShowAttachments"
class="hide-sensitive"
type="text"
@click="showAttachments = false"
:title="$t('cards.toot.hide')"
>
<font-awesome-icon icon="eye" class="hide" />
</el-button>
<div class="media" v-bind:key="media.preview_url" v-for="media in mediaAttachments">
<FailoverImg :src="media.preview_url" :title="media.description" :readExif="true" />
<el-tag class="media-label" size="small" v-if="media.type == 'gifv'">GIF</el-tag>
<el-tag class="media-label" size="small" v-else-if="media.type == 'video'">VIDEO</el-tag>
</div>
</div>
<div class="clearfix"></div>
</div>
<LinkPreview
v-if="status.card && status.card.type === 'link'"
:icon="status.card.image"
:title="status.card.title"
:description="status.card.description"
:url="status.card.url"
/>
</div>
</div>
<div class="clearfix"></div>
</div>
<div class="fill-line"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, toRefs, ref, PropType, watch, nextTick } from 'vue'
import { Entity } from 'megalodon'
import moment from 'moment'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from '@/store'
import { findAccount, findLink, findTag } from '~/src/renderer/utils/tootParser'
import emojify from '~/src/renderer/utils/emojify'
import FailoverImg from '@/components/atoms/FailoverImg.vue'
import LinkPreview from '@/components/molecules/Toot/LinkPreview.vue'
import Filtered from '@/utils/filter'
import { parseDatetime } from '@/utils/datetime'
import { usernameWithStyle } from '@/utils/username'
import { MUTATION_TYPES as SIDEBAR_MUTATION, ACTION_TYPES as SIDEBAR_ACTION } from '@/store/TimelineSpace/Contents/SideBar'
import { ACTION_TYPES as PROFILE_ACTION } from '@/store/TimelineSpace/Contents/SideBar/AccountProfile'
import { ACTION_TYPES as DETAIL_ACTION } from '@/store/TimelineSpace/Contents/SideBar/TootDetail'
export default defineComponent({
name: 'status-reaction',
components: {
FailoverImg,
LinkPreview
},
props: {
message: {
type: Object as PropType<Entity.Notification>,
default: {}
},
filters: {
type: Array as PropType<Array<Entity.Filter>>,
default: []
},
reactionType: {
type: String,
required: true
},
focused: {
type: Boolean,
default: false
},
overlaid: {
type: Boolean,
default: false
}
},
emits: ['select'],
setup(props) {
const store = useStore()
const router = useRouter()
const route = useRoute()
const { focused, message, filters, reactionType } = toRefs(props)
const showContent = ref<boolean>(false)
const showAttachments = ref<boolean>(false)
const notificationRef = ref<any>(null)
const displayNameStyle = computed(() => store.state.App.displayNameStyle)
const timeFormat = computed(() => store.state.App.timeFormat)
const language = computed(() => store.state.App.language)
const hideAllAttachments = computed(() => store.state.App.hideAllAttachments)
// const shortcutEnabled = computed(() => focused.value && !overlaid.value)
const timestamp = computed(() => parseDatetime(message.value.created_at, timeFormat.value, language.value))
const readableTimestamp = computed(() => {
moment.locale(language.value)
return moment(message.value.created_at).format('LLLL')
})
const status = computed(() => message.value.status!)
const spoilered = computed(() => status.value.spoiler_text.length > 0)
const isShowContent = computed(() => !spoilered.value || showContent.value)
const filtered = computed(() => {
if (!message.value.status) {
return false
}
return Filtered(message.value.status.content, filters.value)
})
const mediaAttachments = computed(() => status.value.media_attachments)
const sensitive = computed(() => (hideAllAttachments.value || status.value.sensitive) && mediaAttachments.value.length > 0)
const isShowAttachments = computed(() => !sensitive.value || showAttachments.value)
const statusText = computed(() => emojify(status.value.content, status.value.emojis))
const spoilerText = computed(() => emojify(status.value.spoiler_text, status.value.emojis))
const reactionMessage = computed(() => {
switch (reactionType.value) {
case 'favourite':
return 'notification.favourite.body'
case 'poll-expired':
return 'notification.poll_expired.body'
case 'poll-vote':
return 'notification.poll_vote.body'
case 'quote':
return 'notification.quote.body'
case 'emoji-reaction':
return 'notification.reaction.body'
case 'reblog':
return 'notification.reblog.body'
default:
return 'unknown'
}
})
const reactionClass = computed(() => `action-${reactionType.value}`)
const reactionIcon = computed(() => {
switch (reactionType.value) {
case 'favourite':
return 'star'
case 'poll-expired':
case 'poll-vote':
return 'square-poll-horizontal'
case 'quote':
case 'reblog':
return 'retweet'
default:
return 'unknown'
}
})
watch(focused, (newVal, oldVal) => {
if (newVal) {
nextTick(() => {
notificationRef.value.focus()
})
} else if (oldVal && !newVal) {
nextTick(() => {
notificationRef.value.blur()
})
}
})
const username = (account: Entity.Account) => usernameWithStyle(account, displayNameStyle.value)
const tootClick = (e: MouseEvent) => {
const parsedTag = findTag(e.target as HTMLElement, 'status-reaction')
if (parsedTag !== null) {
const tag = `/${route.params.id}/hashtag/${parsedTag}`
router.push({ path: tag })
return tag
}
const parsedAccount = findAccount(e.target as HTMLElement, 'status-reaction')
if (parsedAccount !== null) {
store.commit(`TimelineSpace/Contents/SideBar/${SIDEBAR_MUTATION.CHANGE_OPEN_SIDEBAR}`, true)
store
.dispatch(`TimelineSpace/Contents/SideBar/AccountProfile/${PROFILE_ACTION.SEARCH_ACCOUNT}`, parsedAccount)
.then(account => {
store.dispatch(`TimelineSpace/Contents/SideBar/${SIDEBAR_ACTION.OPEN_ACCOUNT_COMPONENT}`)
store.dispatch(`TimelineSpace/Contents/SideBar/AccountProfile/${PROFILE_ACTION.CHANGE_ACCOUNT}`, account)
})
.catch(err => {
console.error(err)
openLink(e)
store.commit(`TimelineSpace/Contents/SideBar/${SIDEBAR_MUTATION.CHANGE_OPEN_SIDEBAR}`, false)
})
return parsedAccount.acct
}
return openLink(e)
}
const openLink = (e: MouseEvent) => {
const link = findLink(e.target as HTMLElement, 'status-reaction')
if (link !== null) {
return (window as any).shell.openExternal(link)
}
}
const openUser = (account: Entity.Account) => {
store.dispatch(`TimelineSpace/Contents/SideBar/${SIDEBAR_ACTION.OPEN_ACCOUNT_COMPONENT}`)
store.dispatch(`TimelineSpace/Contents/SideBar/AccountProfile/${PROFILE_ACTION.CHANGE_ACCOUNT}`, account)
store.commit(`TimelineSpace/Contents/SideBar/${SIDEBAR_MUTATION.CHANGE_OPEN_SIDEBAR}`, true)
}
const openDetail = (status: Entity.Status) => {
store.dispatch(`TimelineSpace/Contents/SideBar/${SIDEBAR_ACTION.OPEN_TOOT_COMPONENT}`)
store.dispatch(`TimelineSpace/Contents/SideBar/TootDetail/${DETAIL_ACTION.CHANGE_TOOT}`, status)
store.commit(`TimelineSpace/Contents/SideBar/${SIDEBAR_MUTATION.CHANGE_OPEN_SIDEBAR}`, true)
}
return {
showContent,
showAttachments,
notificationRef,
displayNameStyle,
timeFormat,
language,
hideAllAttachments,
timestamp,
readableTimestamp,
username,
tootClick,
openUser,
openDetail,
mediaAttachments,
filtered,
spoilered,
isShowContent,
sensitive,
isShowAttachments,
status,
statusText,
spoilerText,
reactionMessage,
reactionClass,
reactionIcon
}
}
})
</script>
<style lang="scss" scoped>
.bold {
font-weight: bold;
}
.status-reaction {
padding: 8px 0 0 16px;
.fa-icon {
font-size: 0.9em;
width: auto;
height: 1em;
max-width: 100%;
max-height: 100%;
}
.action {
margin-right: 8px;
.action-mark {
float: left;
width: 32px;
text-align: right;
}
.action-favourite {
color: #e6a23c;
}
.action-poll-expired {
color: #409eff;
}
.action-poll-vote {
color: #67c23a;
}
.action-quote {
color: #409eff;
}
.action-reaction {
color: #e6a23c;
}
.action-reblog {
color: #409eff;
}
.action-detail {
margin-left: 10px;
font-size: var(--base-font-size);
float: left;
max-width: 80%;
.bold {
cursor: pointer;
}
.bold :deep(.emojione) {
max-width: 14px;
max-height: 14px;
}
}
.action-icon {
width: 100%;
text-align: right;
img {
width: 16px;
height: 16px;
border-radius: 2px;
}
}
}
.target {
.icon {
float: left;
width: 42px;
cursor: pointer;
img {
width: 32px;
height: 32px;
border-radius: 4px;
}
}
.detail {
margin: 8px 8px 0 42px;
color: #909399;
.content-wrapper {
font-size: var(--base-font-size);
margin: 0;
.content {
font-size: var(--base-font-size);
word-wrap: break-word;
}
.content p {
unicode-bidi: plaintext;
}
}
.content-wrapper :deep(.emojione) {
width: 20px;
height: 20px;
}
.toot-header {
height: 22px;
.user {
float: left;
font-size: var(--base-font-size);
cursor: pointer;
.display-name :deep(.emojione) {
max-width: 14px;
max-height: 14px;
}
}
.timestamp {
font-size: var(--base-font-size);
text-align: right;
width: 100%;
}
}
.spoiler {
margin: 8px 0;
.spoil-button {
background-color: var(--theme-selected-background-color);
border-color: var(--theme-border-color);
padding: 2px 4px;
}
}
.attachments {
position: relative;
margin: 4px 0 8px;
.show-sensitive {
padding: 20px 32px;
margin-bottom: 4px;
}
.hide-sensitive {
position: absolute;
top: 2px;
left: 2px;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px;
&:hover {
background-color: rgba(0, 0, 0, 0.9);
}
}
.media {
float: left;
margin-right: 8px;
width: 200px;
height: 200px;
img {
cursor: zoom-in;
object-fit: cover;
max-width: 200px;
max-height: 200px;
width: 100%;
height: 100%;
border-radius: 8px;
}
.media-label {
position: absolute;
bottom: 6px;
left: 4px;
color: #fff;
background: rgba(0, 0, 0, 0.5);
}
}
}
}
}
}
.filtered {
align-items: center;
display: flex;
height: 40px;
justify-content: center;
}
.status:focus {
background-color: var(--theme-selected-background-color);
outline: 0;
}
.fill-line {
height: 1px;
background-color: var(--theme-border-color);
margin: 4px 0 0;
}
</style>