fedimovies-web/src/components/Post.vue
2023-03-05 14:57:45 +00:00

753 lines
19 KiB
Vue

<template>
<div
class="post"
:class="{ highlighted: highlighted }"
:data-post-id="post.id"
:id="post.id"
>
<div class="post-header">
<a
class="floating-avatar"
:href="post.account.url"
:title="author.getDisplayName()"
@click="openProfile($event, post.account)"
>
<avatar :profile="post.account"></avatar>
</a>
<a
class="display-name-link"
:href="post.account.url"
:title="author.getDisplayName()"
@click="openProfile($event, post.account)"
>
<profile-display-name :profile="author"></profile-display-name>
</a>
<div class="actor-address" :title="'@' + getActorAddress(post.account)">
@{{ getActorAddress(post.account) }}
</div>
<a
v-if="inThread && post.in_reply_to_id"
class="icon"
title="Go to previous post"
@mouseover="highlight(post.in_reply_to_id)"
@mouseleave="highlight(null)"
@click.prevent="scrollTo(post.in_reply_to_id)"
>
<img :src="require('@/assets/tabler/corner-left-up.svg')">
</a>
<span
class="icon icon-small"
:title="getVisibilityDisplay()"
>
<visibility-icon :visibility="post.visibility"></visibility-icon>
</span>
<span v-if="post.edited_at">edited</span>
<a
class="timestamp"
:href="post.uri"
:title="formatDateTime(post.created_at)"
@click="openPost($event, post.id)"
>
{{ humanizeDate(post.created_at) }}
</a>
</div>
<div class="post-subheader" v-if="getReplyMentions().length > 0">
<span>replying to</span>
<a
v-for="mention in getReplyMentions()"
:key="mention.id"
:href="mention.url"
:title="getActorAddress(mention)"
@click="openProfile($event, mention)"
>
@{{ mention.username }}
</a>
</div>
<post-content :post="post"></post-content>
<div class="post-attachments" v-if="post.media_attachments.length > 0">
<post-attachment
v-for="attachment in post.media_attachments"
:attachment="attachment"
:key="attachment.id"
></post-attachment>
</div>
<a
v-for="linkedPost in post.links"
class="post-quote"
:href="linkedPost.uri"
:key="linkedPost.id"
@click="openPost($event, linkedPost.id, true)"
>
<div class="quote-header">
<avatar :profile="linkedPost.account"></avatar>
<profile-display-name :profile="getQuoteAuthor(linkedPost)">
</profile-display-name>
<span class="actor-address">
@{{ getActorAddress(linkedPost.account) }}
</span>
</div>
<post-content :post="linkedPost"></post-content>
<div class="quote-attachments" v-if="linkedPost.media_attachments.length > 0">
<post-attachment
v-for="attachment in linkedPost.media_attachments"
:attachment="attachment"
:key="attachment.id"
></post-attachment>
</div>
</a>
<div class="post-footer">
<router-link
v-if="!inThread"
class="icon"
title="View replies"
:to="{ name: 'post', params: { postId: post.id }}"
>
<img :src="require('@/assets/forkawesome/comment-o.svg')">
<span>{{ post.replies_count }}</span>
</router-link>
<button
v-else-if="inThread && canReply()"
class="icon"
title="Reply"
@click="commentFormVisible = !commentFormVisible"
>
<img :src="require('@/assets/forkawesome/comment-o.svg')">
<span>{{ post.replies_count }}</span>
</button>
<span v-else class="icon">
<img :src="require('@/assets/forkawesome/comment-o.svg')">
<span>{{ post.replies_count }}</span>
</span>
<button
v-if="canRepost()"
class="icon"
:class="{ highlighted: post.reblogged }"
title="Repost"
@click="toggleRepost()"
>
<img :src="require('@/assets/feather/repeat.svg')">
<span>{{ post.reblogs_count }}</span>
</button>
<span v-else class="icon">
<img :src="require('@/assets/feather/repeat.svg')">
<span>{{ post.reblogs_count }}</span>
</span>
<button
v-if="canLike()"
class="icon"
:class="{ highlighted: post.favourited }"
title="Like"
@click="toggleLike()"
>
<img :src="require('@/assets/forkawesome/thumbs-o-up.svg')">
<span>{{ post.favourites_count }}</span>
</button>
<span v-else class="icon">
<img :src="require('@/assets/forkawesome/thumbs-o-up.svg')">
<span>{{ post.favourites_count }}</span>
</span>
<a
v-if="getIpfsUrl()"
class="icon"
title="Saved to IPFS"
:href="getIpfsUrl() || ''"
target="_blank"
rel="noreferrer"
>
<img :src="require('@/assets/ipfs.svg')">
</a>
<router-link
v-if="isTokenized()"
class="icon tokenized"
title="View token"
:to="{ name: 'post-overlay', params: { postId: post.id }}"
>
<img :src="require('@/assets/forkawesome/diamond.svg')">
</router-link>
<a
v-if="isWaitingForToken"
class="icon tokenize-progress"
title="Tokenizing..."
>
<img :src="require('@/assets/forkawesome/diamond.svg')">
</a>
<div
class="dropdown-menu-wrapper"
v-if="canSaveToIpfs() || canMintToken() || canDeletePost()"
v-click-away="hideMenu"
>
<button class="icon" title="More" @click="toggleMenu()">
<img :src="require('@/assets/feather/more-horizontal.svg')">
</button>
<menu v-if="menuVisible" class="dropdown-menu">
<li v-if="canSaveToIpfs()">
<button
class="icon"
title="Save to IPFS"
@click="hideMenu(); saveToIpfs()"
>
<img :src="require('@/assets/ipfs.svg')">
<span>Save to IPFS</span>
</button>
</li>
<li v-if="canMintToken()">
<button
class="icon"
title="Mint NFT"
@click="hideMenu(); onMintToken()"
>
<img :src="require('@/assets/forkawesome/diamond.svg')">
<span>Mint NFT</span>
</button>
</li>
<li v-if="canDeletePost()">
<button
class="icon"
title="Delete post"
@click="hideMenu(); onDeletePost()"
>
<img :src="require('@/assets/feather/trash.svg')">
<span>Delete post</span>
</button>
</li>
</menu>
</div>
<div class="crypto-widget">
<crypto-address
v-if="selectedPaymentAddress"
:address="selectedPaymentAddress"
></crypto-address>
<button
v-for="option in getPaymentOptions()"
:key="option.code"
class="icon"
:title="'Send '+ option.name"
@click="togglePaymentAddress(option)"
>
<img :src="require('@/assets/cryptoicons/' + option.code.toLowerCase() + '.svg')">
</button>
</div>
</div>
<post-editor
v-if="commentFormVisible"
:in-reply-to="post"
@post-created="onCommentCreated"
>
</post-editor>
</div>
</template>
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import { $, $computed, $ref } from "vue/macros"
import { useRouter } from "vue-router"
import {
makePermanent,
getMintingAuthorization,
mintToken,
onTokenMinted,
} from "@/api/nft"
import {
VISIBILITY_MAP,
Mention,
Post,
getPost,
deletePost,
favourite,
unfavourite,
createRepost,
deleteRepost,
} from "@/api/posts"
import {
Permissions,
Profile,
ProfileWrapper,
} from "@/api/users"
import Avatar from "@/components/Avatar.vue"
import CryptoAddress from "@/components/CryptoAddress.vue"
import PostAttachment from "@/components/PostAttachment.vue"
import PostContent from "@/components/PostContent.vue"
import PostEditor from "@/components/PostEditor.vue"
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
import VisibilityIcon from "@/components/VisibilityIcon.vue"
import { useInstanceInfo } from "@/store/instance"
import { useCurrentUser } from "@/store/user"
import { CRYPTOCURRENCIES } from "@/utils/cryptocurrencies"
import { getWallet } from "@/utils/ethereum"
import { formatDateTime, humanizeDate } from "@/utils/dates"
interface PaymentOption {
code: string;
name: string;
address: string;
}
const router = useRouter()
const { currentUser, ensureAuthToken } = $(useCurrentUser())
const { instance, getActorAddress } = $(useInstanceInfo())
/* eslint-disable-next-line no-undef */
const props = defineProps<{
post: Post,
highlighted: boolean,
inThread: boolean,
}>()
/* eslint-disable-next-line no-undef, func-call-spacing */
const emit = defineEmits<{
(event: "highlight", postId: string | null): void,
(event: "navigate-to", postId: string): void,
(event: "comment-created", post: Post): void,
(event: "post-deleted"): void,
}>()
let commentFormVisible = $ref(false)
let menuVisible = $ref(false)
let selectedPaymentAddress = $ref<string | null>(null)
let isWaitingForToken = $ref(false)
const blockchain = $computed(() => instance?.blockchains[0])
const author = $computed(() => new ProfileWrapper(props.post.account))
function openProfile(event: Event, profile: Mention | Profile) {
if (currentUser === null) {
// Viewing as guest; do not override click handler
return
}
event.preventDefault()
router.push({ name: "profile-by-acct", params: { acct: profile.acct } })
}
function highlight(postId: string | null) {
emit("highlight", postId)
}
function scrollTo(postId: string) {
emit("navigate-to", postId)
}
function openPost(event: Event, postId: string, forcePush = false) {
if (currentUser === null) {
// Viewing as guest; do not override click handler
return
}
event.preventDefault()
if (props.inThread && !forcePush) {
scrollTo(postId)
} else {
router.push({ name: "post", params: { postId: postId } })
}
}
function getVisibilityDisplay(): string {
return VISIBILITY_MAP[props.post.visibility]
}
function getReplyMentions(): Mention[] {
if (props.post.in_reply_to_id === null) {
return []
}
return props.post.mentions
}
function getQuoteAuthor(post: Post): ProfileWrapper {
return new ProfileWrapper(post.account)
}
function canReply(): boolean {
if (currentUser === null) {
return false
}
return currentUser.role.permissions.includes(Permissions.CreatePost)
}
function onCommentCreated(post: Post) {
commentFormVisible = false
emit("comment-created", post)
}
function canRepost(): boolean {
if (currentUser === null) {
return false
}
return (
props.post.visibility === "public" &&
currentUser.role.permissions.includes(Permissions.CreatePost)
)
}
async function toggleRepost() {
if (!currentUser) {
return
}
const authToken = ensureAuthToken()
let updatedPost
try {
if (props.post.reblogged) {
updatedPost = await deleteRepost(authToken, props.post.id)
} else {
updatedPost = await createRepost(authToken, props.post.id)
}
} catch (error) {
console.log(error)
return
}
props.post.reblogs_count = updatedPost.reblogs_count
props.post.reblogged = updatedPost.reblogged
}
function canLike(): boolean {
return currentUser !== null
}
async function toggleLike() {
if (!currentUser) {
return
}
const authToken = ensureAuthToken()
let updatedPost
try {
if (props.post.favourited) {
updatedPost = await unfavourite(authToken, props.post.id)
} else {
updatedPost = await favourite(authToken, props.post.id)
}
} catch (error) {
console.log(error)
return
}
props.post.favourites_count = updatedPost.favourites_count
props.post.favourited = updatedPost.favourited
}
function toggleMenu() {
menuVisible = !menuVisible
}
function hideMenu() {
menuVisible = false
}
function getIpfsUrl(): string | null {
const gatewayUrl = instance?.ipfs_gateway_url
if (
!gatewayUrl ||
props.post.ipfs_cid === null ||
isTokenized() ||
isWaitingForToken
) {
return null
}
return `${gatewayUrl}/ipfs/${props.post.ipfs_cid}`
}
function canSaveToIpfs(): boolean {
return (
Boolean(instance?.ipfs_gateway_url) &&
props.post.account.id === currentUser?.id &&
props.post.visibility === "public" &&
props.post.ipfs_cid === null &&
!isWaitingForToken
)
}
async function saveToIpfs() {
if (!currentUser || !instance || !instance.ipfs_gateway_url) {
return
}
const authToken = ensureAuthToken()
const { ipfs_cid } = await makePermanent(authToken, props.post.id)
props.post.ipfs_cid = ipfs_cid
}
function canDeletePost(): boolean {
return props.post.account.id === currentUser?.id
}
async function onDeletePost() {
if (confirm("Are you sure you want to delete this post?")) {
const authToken = ensureAuthToken()
await deletePost(authToken, props.post.id)
emit("post-deleted")
}
}
function getPaymentOptions(): PaymentOption[] {
const items = []
for (const [code, name] of CRYPTOCURRENCIES) {
const symbol = `$${code}`
const field = props.post.account.fields.find(item => {
return item.name.toLowerCase() === symbol.toLowerCase()
})
if (!field) {
continue
}
const address = field.value.trim()
items.push({ code, name, address })
}
return items
}
function togglePaymentAddress(payment: PaymentOption) {
selectedPaymentAddress = selectedPaymentAddress === payment.address ? null : payment.address
}
function isTokenized(): boolean {
return props.post.token_id !== null
}
function canMintToken(): boolean {
return (
Boolean(instance?.ipfs_gateway_url) &&
Boolean(blockchain?.contract_address) &&
Boolean(blockchain?.features.minter) &&
props.post.account.id === currentUser?.id &&
props.post.visibility === "public" &&
author.getVerifiedEthereumAddress() !== null &&
!isTokenized() &&
!isWaitingForToken
)
}
async function onMintToken() {
if (
!instance ||
!blockchain?.contract_address
) {
return
}
if (isTokenized() || isWaitingForToken) {
return
}
const authorAddress = author.getVerifiedEthereumAddress()
if (!authorAddress) {
return
}
const authToken = ensureAuthToken()
isWaitingForToken = true
if (props.post.ipfs_cid === null) {
const { ipfs_cid } = await makePermanent(authToken, props.post.id)
props.post.ipfs_cid = ipfs_cid
}
const tokenUri = `ipfs://${props.post.ipfs_cid}`
console.info("token URI:", tokenUri)
let signature
try {
signature = await getMintingAuthorization(authToken, props.post.id)
} catch (error) {
console.log(error)
isWaitingForToken = false
return
}
const signer = await getWallet()
if (!signer) {
isWaitingForToken = false
return
}
try {
const transaction = await mintToken(
blockchain.contract_address,
signer,
authorAddress,
tokenUri,
signature,
)
await onTokenMinted(authToken, props.post.id, transaction.hash)
} catch (error) {
// User has rejected tx
isWaitingForToken = false
return
}
// Wait until the server sees the tx
const intervalId = setInterval(async () => {
const updatedPost = await getPost(authToken, props.post.id)
if (updatedPost.token_id) {
clearInterval(intervalId)
isWaitingForToken = false
// Update post
props.post.token_id = updatedPost.token_id
props.post.token_tx_id = updatedPost.token_tx_id
}
}, 5000)
}
</script>
<style scoped lang="scss">
@import "../styles/layout";
@import "../styles/theme";
@import "../styles/mixins";
.post {
background-color: $block-background-color;
border-radius: $block-border-radius;
text-align: left;
&.highlighted {
outline: 1px solid #FFA500;
}
}
.post-header {
@include post-icon;
align-items: center;
color: $secondary-text-color;
display: flex;
flex-direction: row;
gap: calc($block-inner-padding / 2);
padding: $block-inner-padding $block-inner-padding 0;
.floating-avatar {
@include floating-avatar;
@media screen and (min-width: $screen-breakpoint-medium + 1) {
margin-right: calc(0px - $block-inner-padding / 2);
}
}
.display-name-link {
color: $text-color;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actor-address {
flex-basis: 25%;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
user-select: all;
}
.timestamp {
color: $secondary-text-color;
text-align: right;
white-space: nowrap;
&:hover {
color: $secondary-text-hover-color;
}
}
}
.post-subheader {
color: $secondary-text-color;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: $whitespace;
padding: calc($block-inner-padding / 4) $block-inner-padding 0;
a {
@include block-link;
overflow: hidden;
word-wrap: break-word;
}
}
.post-attachments,
.quote-attachments {
padding: 0 $block-inner-padding $block-inner-padding;
}
.post-quote {
border: 1px solid $separator-color;
border-radius: $block-border-radius;
color: inherit;
display: block;
margin: 0 $block-inner-padding $block-inner-padding;
&:hover {
color: inherit;
}
}
.quote-header {
align-items: center;
color: $secondary-text-color;
display: flex;
flex-direction: row;
gap: calc($block-inner-padding / 2);
padding: $block-inner-padding $block-inner-padding 0;
.avatar {
height: $icon-size;
width: $icon-size;
}
.display-name {
color: $text-color;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actor-address {
overflow: hidden;
text-overflow: ellipsis;
}
}
.post-footer {
@include post-icon;
align-items: center;
display: flex;
flex-direction: row;
gap: calc($block-inner-padding / 2);
padding: 0 $block-inner-padding $block-inner-padding;
.icon {
&.tokenized img {
filter: invert(51%) sepia(48%) saturate(437%) hue-rotate(222deg) brightness(92%) contrast(84%);
}
&.tokenize-progress img {
animation: spin 1s linear infinite;
}
}
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.dropdown-menu-wrapper {
@include block-dropdown-menu;
}
.crypto-widget {
display: flex;
flex-grow: 1;
gap: calc($block-inner-padding / 2);
justify-content: right;
.crypto-address {
max-width: 200px;
width: 100%;
}
.icon img {
/* Make filled icons lighter to match line icons */
opacity: 0.75;
}
}
.post-form {
border-top: 1px solid #f3f2ed;
}
@media screen and (max-width: $screen-breakpoint-small) {
.post-footer {
flex-wrap: wrap;
}
}
</style>