fedimovies-web/src/views/Profile.vue

925 lines
23 KiB
Vue

<template>
<sidebar-layout>
<template #content>
<div class="not-found" v-if="!profile && !isLoading">
Profile not found
</div>
<div
class="profile-block"
v-if="profile"
:data-profile-id="profile.id"
>
<div class="profile-header">
<img v-if="profile.header" :src="profile.header">
</div>
<div class="profile-info-group">
<div class="avatar-menu-group">
<div class="avatar-group">
<avatar :profile="profile"></avatar>
<div class="badges">
<div
class="badge"
v-if="aliases.length > 0"
:title="aliases.map(profile => '@' + profile.acct).join(', ')"
>
Alias
</div>
<div class="badge" v-if="isFollowedBy()">Follows you</div>
<div class="badge" v-if="isSubscriptionValid()">Subscription</div>
<div class="badge" v-if="isSubscriber()">Subscriber</div>
<div class="badge" v-if="isMuted()">Muted</div>
</div>
</div>
<div
class="dropdown-menu-wrapper"
v-click-away="hideProfileMenu"
>
<button title="More" @click="toggleProfileMenu()">
<img src="@/assets/feather/more-vertical.svg">
</button>
<menu v-if="profileMenuVisible" class="dropdown-menu">
<li v-if="!isLocalUser()">
<a
title="Open profile page"
:href="profile.url"
target="_blank"
rel="noreferrer"
@click="hideProfileMenu()"
>
Open profile page
</a>
</li>
<li v-if="isLocalUser()">
<a
:href="feedUrl"
target="_blank"
>
Atom feed
</a>
</li>
<li v-if="canVerifyEthereumAddress()">
<button
title="Link ethereum address"
@click="hideProfileMenu(); onVerifyEthereumAddress()"
>
Link ethereum address
</button>
</li>
<li v-if="isCurrentUser()">
<router-link
title="Link minisign key"
:to="{ name: 'identity-proof' }"
>
Link minisign key
</router-link>
</li>
<li v-if="canVerifyEthereumAddress()">
<button
title="Send signed update"
@click="hideProfileMenu(); onSignActivity()"
>
Send signed update
</button>
</li>
<li v-if="canManageSubscriptions()">
<router-link
title="Manage subscriptions"
:to="{ name: 'subscriptions-settings' }"
>
Manage subscriptions
</router-link>
</li>
<li v-if="canHideReposts()">
<button @click="onFollow(false, undefined)">Hide reposts</button>
</li>
<li v-if="canShowReposts()">
<button @click="onFollow(true, undefined)">Show reposts</button>
</li>
<li v-if="canHideReplies()">
<button @click="onFollow(undefined, false)">Hide replies</button>
</li>
<li v-if="canShowReplies()">
<button @click="onFollow(undefined, true)">Show replies</button>
</li>
<li v-if="!isMuted()">
<button @click="onMute()">Mute</button>
</li>
<li v-if="isMuted()">
<button @click="onUnmute()">Unmute</button>
</li>
<li v-if="isAdmin()">
<button
title="Copy profile ID"
@click="hideProfileMenu(); copyProfileId()"
>
Copy profile ID
</button>
</li>
</menu>
</div>
</div>
<div class="name-buttons-group">
<div class="name-group">
<profile-display-name :profile="profile">
</profile-display-name>
<div class="actor-address">@{{ actorAddress }}</div>
</div>
<div class="buttons">
<router-link
v-if="isCurrentUser()"
class="edit-profile btn"
:to="{ name: 'settings-profile' }"
>
Edit profile
</router-link>
<button v-if="canFollow()" class="follow btn" @click="onFollow()">
<span>Follow</span>
<img
v-if="profile.locked"
title="Manually approves followers"
src="@/assets/forkawesome/lock.svg"
>
</button>
<button v-if="canUnfollow()" class="unfollow btn" @click="onUnfollow()">
<template v-if="isFollowRequestPending()">Cancel follow request</template>
<template v-else>Unfollow</template>
</button>
<template v-if="canSubscribe()">
<a
v-if="typeof profile.getSubscriptionPageLocation() === 'string'"
class="btn"
title="Pay for subscription"
:href="profile.getSubscriptionPageLocation() as string"
target="_blank"
rel="noreferrer"
>
Subscribe
</a>
<router-link
v-else-if="profile.getSubscriptionPageLocation() !== null"
class="btn"
title="Pay for subscription"
:to="profile.getSubscriptionPageLocation()"
>
Subscribe
</router-link>
</template>
</div>
</div>
<div class="bio" v-html="profile.note"></div>
<div class="extra-fields" v-if="fields.length > 0">
<div
v-for="field in fields"
class="field"
:class="{ verified: field.verified_at }"
:key="field.name"
>
<div class="name" :title="field.name">{{ field.name }}</div>
<div class="value" v-html="field.value"></div>
<div class="verified-icon" v-if="field.verified_at">
<img
src="@/assets/forkawesome/check.svg"
title="Verified"
>
</div>
</div>
</div>
<div v-if="isLocalUser()" class="stats">
<component
class="stats-item"
:is="isCurrentUser() ? 'a' : 'span'"
@click="isCurrentUser() && switchTab('posts')"
>
<span class="value">{{ profile.statuses_count }}</span>
<span class="label">posts</span>
</component>
<component
class="stats-item"
:is="isCurrentUser() ? 'a' : 'span'"
@click="isCurrentUser() && switchTab('followers')"
>
<span class="value">{{ profile.followers_count }}</span>
<span class="label">followers</span>
</component>
<component
class="stats-item"
:is="isCurrentUser() ? 'a' : 'span'"
@click="isCurrentUser() && switchTab('following')"
>
<span class="value">{{ profile.following_count }}</span>
<span class="label">following</span>
</component>
<component
class="stats-item"
v-if="isSubscriptionsFeatureEnabled()"
:is="isCurrentUser() ? 'a' : 'span'"
@click="isCurrentUser() && switchTab('subscribers')"
>
<span class="value">{{ profile.subscribers_count }}</span>
<span class="label">subscribers</span>
</component>
</div>
</div>
</div>
<div class="tab-bar" v-if="profile">
<template v-if="tabName === 'posts' || tabName === 'posts-with-replies'">
<a
:class="{ active: tabName === 'posts' }"
@click="switchTab('posts')"
>
Posts
</a>
<a
:class="{ active: tabName === 'posts-with-replies' }"
@click="switchTab('posts-with-replies')"
>
Posts and replies
</a>
</template>
<span v-else-if="tabName === 'followers'" class="active">Followers</span>
<span v-else-if="tabName === 'following'" class="active">Following</span>
<span v-else-if="tabName === 'subscribers'" class="active">Subscribers</span>
</div>
<loader v-if="isLoading"></loader>
<div :style="{ visibility: isLoading ? 'hidden' : 'visible' }">
<post-list
v-if="tabName === 'posts' || tabName === 'posts-with-replies'"
:posts="posts"
@load-next-page="loadNextPage"
></post-list>
<template v-else-if="tabName === 'followers' || tabName === 'following' || tabName === 'subscribers'">
<router-link
v-for="profile in followList"
:key="profile.id"
:to="{ name: 'profile-by-acct', params: { acct: profile.acct } }"
>
<profile-list-item :profile="profile"></profile-list-item>
</router-link>
<button
v-if="followListNextPageUrl"
class="btn secondary next-btn"
@click="loadFollowListNextPage()"
>
Show more profiles
</button>
</template>
</div>
</template>
</sidebar-layout>
</template>
<script setup lang="ts">
import { onMounted } from "vue"
import { $, $ref, $computed } from "vue/macros"
import { useRoute } from "vue-router"
import { Post, getProfileTimeline } from "@/api/posts"
import {
follow,
unfollow,
Relationship,
getRelationship,
getFollowers,
getFollowing,
mute,
unmute,
} from "@/api/relationships"
import { getReceivedSubscriptions } from "@/api/subscriptions-common"
import {
getAliases,
getProfile,
lookupProfile,
Permissions,
Profile,
ProfileField,
ProfileWrapper,
EXTRA_FIELD_COUNT_MAX,
} from "@/api/users"
import Avatar from "@/components/Avatar.vue"
import Loader from "@/components/Loader.vue"
import PostList from "@/components/PostList.vue"
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
import ProfileListItem from "@/components/ProfileListItem.vue"
import SidebarLayout from "@/components/SidebarLayout.vue"
import { useEthereumAddressVerification } from "@/composables/ethereum-address-verification"
import { useInstanceInfo } from "@/composables/instance"
import { useSignedActivity } from "@/composables/signed-activity"
import { useCurrentUser } from "@/composables/user"
import { BACKEND_URL } from "@/constants"
import { hasEthereumWallet } from "@/utils/ethereum"
const route = useRoute()
const {
authToken,
currentUser,
ensureAuthToken,
} = $(useCurrentUser())
const { verifyEthereumAddress } = useEthereumAddressVerification()
const { getActorAddress, getBlockchainInfo } = $(useInstanceInfo())
let profile = $ref<ProfileWrapper | null>(null)
let relationship = $ref<Relationship | null>(null)
let aliases = $ref<Profile[]>([])
let profileMenuVisible = $ref(false)
let tabName = $ref("posts")
let isLoading = $ref(false)
let posts = $ref<Post[]>([])
let followList = $ref<Profile[]>([])
let followListNextPageUrl = $ref<string | null>(null)
onMounted(async () => {
isLoading = true
try {
let _profile
if (route.params.acct) {
_profile = await lookupProfile(
authToken,
route.params.acct as string,
)
} else {
_profile = await getProfile(
authToken,
route.params.profileId as string,
)
}
profile = new ProfileWrapper(_profile)
} catch (error: any) {
if (error.message === "profile not found") {
// Show "not found" text
isLoading = false
return
}
throw error
}
if (currentUser && !isCurrentUser()) {
relationship = await getRelationship(
ensureAuthToken(),
profile.id,
)
}
if (profile.identity_proofs.length > 0) {
const { verified } = await getAliases(profile.id)
aliases = verified
}
await switchTab("posts")
isLoading = false
})
async function switchTab(name: string) {
if (!profile) {
return
}
isLoading = true
tabName = name
if (tabName === "posts") {
posts = await getProfileTimeline(
authToken,
profile.id,
true,
)
} else if (tabName === "posts-with-replies") {
posts = await getProfileTimeline(
authToken,
profile.id,
false,
)
} else if (tabName === "followers" && isCurrentUser()) {
const page = await getFollowers(
ensureAuthToken(),
profile.id,
)
followList = page.profiles
followListNextPageUrl = page.nextPageUrl
} else if (tabName === "following" && isCurrentUser()) {
const page = await getFollowing(
ensureAuthToken(),
profile.id,
)
followList = page.profiles
followListNextPageUrl = page.nextPageUrl
} else if (tabName === "subscribers" && isCurrentUser()) {
const subscriptions = await getReceivedSubscriptions(
ensureAuthToken(),
profile.id,
)
followList = subscriptions.map((subscription) => subscription.sender)
followListNextPageUrl = null
}
isLoading = false
}
const actorAddress = $computed<string>(() => {
if (!profile) {
return ""
}
return getActorAddress(profile)
})
const fields = $computed<ProfileField[]>(() => {
if (!profile) {
return []
}
return profile.identity_proofs
.concat(profile.fields)
.slice(0, EXTRA_FIELD_COUNT_MAX)
})
function isCurrentUser(): boolean {
if (!currentUser || !profile) {
return false
}
return currentUser.id === profile.id
}
function isFollowedBy(): boolean {
if (!relationship) {
return false
}
return relationship.followed_by
}
function isSubscriptionValid(): boolean {
if (!relationship) {
return false
}
return relationship.subscription_to
}
function isSubscriber(): boolean {
if (!relationship) {
return false
}
return relationship.subscription_from
}
function isMuted(): boolean {
if (!relationship) {
return false
}
return relationship.muting
}
function canFollow(): boolean {
if (!relationship) {
return false
}
return !relationship.following && !relationship.requested
}
function canUnfollow(): boolean {
if (!relationship) {
return false
}
return (relationship.following || relationship.requested)
}
function isFollowRequestPending(): boolean {
if (!relationship) {
return false
}
return relationship.requested
}
function canHideReposts(): boolean {
if (!relationship) {
return false
}
return (relationship.following || relationship.requested) && relationship.showing_reblogs
}
function canShowReposts(): boolean {
if (!relationship) {
return false
}
return (relationship.following || relationship.requested) && !relationship.showing_reblogs
}
function canHideReplies(): boolean {
if (!relationship) {
return false
}
return (relationship.following || relationship.requested) && relationship.showing_replies
}
function canShowReplies(): boolean {
if (!relationship) {
return false
}
return (relationship.following || relationship.requested) && !relationship.showing_replies
}
async function onFollow(showReposts?: boolean, showReplies?: boolean) {
if (!currentUser || !profile || !relationship) {
return
}
relationship = await follow(
ensureAuthToken(),
profile.id,
showReposts ?? relationship.showing_reblogs,
showReplies ?? relationship.showing_replies,
)
}
async function onMute() {
if (!currentUser || !profile) {
return
}
relationship = await mute(
ensureAuthToken(),
profile.id,
)
}
async function onUnmute() {
if (!currentUser || !profile) {
return
}
relationship = await unmute(
ensureAuthToken(),
profile.id,
)
}
async function onUnfollow() {
if (!currentUser || !profile) {
return
}
relationship = await unfollow(
ensureAuthToken(),
profile.id,
)
}
function toggleProfileMenu() {
profileMenuVisible = !profileMenuVisible
}
function hideProfileMenu() {
profileMenuVisible = false
}
function isLocalUser(): boolean {
if (!profile) {
return false
}
return profile.username === profile.acct
}
const feedUrl = $computed<string>(() => {
if (!profile || !isLocalUser()) {
return ""
}
return `${BACKEND_URL}/feeds/users/${profile.username}`
})
function canVerifyEthereumAddress(): boolean {
return isCurrentUser() && hasEthereumWallet()
}
async function onVerifyEthereumAddress() {
if (!profile || !isCurrentUser()) {
return
}
const user = await verifyEthereumAddress()
if (user) {
profile.identity_proofs = user.identity_proofs
}
}
async function onSignActivity() {
if (!profile || !isCurrentUser()) {
return
}
const { signUpdateActivity } = useSignedActivity()
await signUpdateActivity()
}
function isSubscriptionsFeatureEnabled(): boolean {
const blockchain = getBlockchainInfo()
return Boolean(blockchain?.features.subscriptions)
}
function canManageSubscriptions(): boolean {
return (
isSubscriptionsFeatureEnabled() &&
profile !== null &&
currentUser !== null &&
isCurrentUser() &&
currentUser.role.permissions.includes(Permissions.ManageSubscriptionOptions)
)
}
function canSubscribe(): boolean {
return (
isSubscriptionsFeatureEnabled() &&
profile !== null &&
profile.getSubscriptionPageLocation() !== null &&
!isCurrentUser()
)
}
function isAdmin(): boolean {
if (!currentUser) {
return false
}
return currentUser.role.permissions.includes(Permissions.DeleteAnyProfile)
}
function copyProfileId(): void {
if (!profile) {
return
}
navigator.clipboard.writeText(profile.id)
}
async function loadNextPage(maxId: string) {
if (!profile) {
return
}
const nextPage = await getProfileTimeline(
authToken,
profile.id,
tabName !== "posts-with-replies",
maxId,
)
posts = [...posts, ...nextPage]
}
async function loadFollowListNextPage() {
if (!profile || !followListNextPageUrl) {
return
}
let loadFollowList
if (tabName === "followers") {
loadFollowList = getFollowers
} else if (tabName === "following") {
loadFollowList = getFollowing
} else {
throw new Error("wrong tab")
}
const page = await loadFollowList(
ensureAuthToken(),
profile.id,
followListNextPageUrl,
)
followList.push(...page.profiles)
followListNextPageUrl = page.nextPageUrl
}
</script>
<style scoped lang="scss">
@import "../styles/layout";
@import "../styles/mixins";
@import "../styles/theme";
$avatar-size: 185px;
.profile-block {
@include block-btn;
background-color: var(--block-background-color);
border-radius: $block-border-radius;
margin-bottom: $block-outer-padding;
.profile-header {
background-color: var(--btn-background-color);
border-radius: $block-border-radius $block-border-radius 0 0;
height: 200px;
img {
border-radius: inherit;
height: 100%;
object-fit: cover;
width: 100%;
}
}
}
.profile-info-group {
display: flex;
flex-direction: column;
gap: $block-inner-padding;
padding: $block-inner-padding;
}
.avatar-menu-group {
display: flex;
flex-direction: row;
gap: $block-inner-padding;
.avatar-group {
align-items: flex-start;
display: flex;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
gap: calc($block-inner-padding / 2) $block-inner-padding;
}
.avatar {
height: $avatar-size * 1.5;
margin-right: auto;
margin-top: calc(-1 * ($avatar-size / 2 + $block-inner-padding));
min-width: $avatar-size;
padding: 7px;
width: $avatar-size;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: calc($block-inner-padding / 2) $block-inner-padding;
}
.badge {
border: 1px solid var(--btn-background-color);
border-radius: $btn-border-radius;
font-size: 14px;
line-height: 30px;
padding: 0 7px;
white-space: nowrap;
}
.dropdown-menu-wrapper {
@include block-dropdown-menu;
/* stylelint-disable-next-line selector-max-compound-selectors */
button img {
filter: var(--link-colorizer);
height: 32px;
min-width: 20px;
object-fit: none;
width: 20px;
/* stylelint-disable-next-line selector-max-compound-selectors */
&:hover {
filter: var(--link-hover-colorizer);
}
}
.dropdown-menu {
right: 0;
}
}
}
.name-buttons-group {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: $block-inner-padding;
.name-group {
flex-grow: 1;
font-size: 16px;
line-height: 1.3;
overflow-x: hidden;
.display-name {
font-weight: bold;
}
.actor-address {
color: var(--secondary-text-color);
overflow-x: hidden;
text-overflow: ellipsis;
user-select: all;
}
}
.buttons {
display: flex;
gap: $block-inner-padding;
}
}
.follow {
align-items: center;
display: flex;
gap: $input-padding;
img {
$icon-size: 1em;
filter: var(--btn-text-colorizer);
height: $icon-size;
min-width: $icon-size;
width: $icon-size;
}
&:hover {
img {
filter: var(--text-colorizer);
}
}
}
.bio {
white-space: pre-line;
word-wrap: break-word;
:deep(a) {
@include block-link;
}
}
.extra-fields {
border-bottom: 1px solid var(--separator-color);
.field {
border-top: 1px solid var(--separator-color);
display: flex;
gap: calc($block-inner-padding / 2);
padding: calc($block-inner-padding / 2) 0;
.name {
font-weight: bold;
min-width: 120px;
overflow-x: hidden;
text-overflow: ellipsis;
width: 120px;
}
.value {
flex-grow: 1;
overflow-x: hidden;
text-overflow: ellipsis;
}
&.verified .value {
font-weight: bold;
}
/* stylelint-disable-next-line selector-max-compound-selectors */
.verified-icon img {
filter: var(--text-colorizer);
height: 1em;
min-width: 1em;
width: 1em;
}
}
&:last-child {
border-bottom: none;
}
}
.stats {
display: flex;
flex-direction: row;
flex-wrap: wrap;
font-weight: bold;
gap: $block-inner-padding 30px;
text-align: center;
.stats-item {
display: flex;
gap: 5px;
.value {
font-size: 18px;
}
.label {
align-self: flex-end;
color: var(--secondary-text-color);
}
}
}
.tab-bar {
align-items: center;
display: flex;
margin-bottom: $block-outer-padding;
a,
span {
border-radius: $block-border-radius;
padding: calc($block-inner-padding / 2);
text-align: center;
width: 100%;
&.active {
background-color: var(--block-background-color);
font-weight: bold;
}
}
}
/* profile-list-item */
.profile {
margin-bottom: $block-outer-padding;
}
.loader {
margin: 0 auto;
}
.not-found {
font-size: 20px;
text-align: center;
}
</style>