You've already forked AstralRinth
* Start of app redesign * format * continue progress * Content page nearly done * Fix recursion issues with content page * Fix update all alignment * Discover page progress * Settings progress * Removed unlocked-size hack that breaks web * Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues * Ads + run prettier * Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor * fix ads not hiding when plus log in * rev lockfile changes/conflicts * Fix sign in page * Add generated * (mostly) Data driven search * Fix search mobile issue * profile fixes * Project versions page, fix typescript on UI lib and misc fixes * Remove unused gallery component * Fix linkfunction err * Search filter controls at top, localization for locked filters * Fix provided filter names * Fix navigating from instance browse to main browse * Friends frontend (#2995) * Friends system frontend * (almost) finish frontend * finish friends, fix lint * Fix lint --------- Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com> * Refresh macOS app icon * Update web search UI more * Fix link opens * Fix frontend build --------- Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
360 lines
11 KiB
Vue
360 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { Avatar, ButtonStyled, NewModal, OverflowMenu } from '@modrinth/ui'
|
|
import {
|
|
UserPlusIcon,
|
|
MoreVerticalIcon,
|
|
MailIcon,
|
|
SettingsIcon,
|
|
TrashIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import { ref, onUnmounted, watch, computed } from 'vue'
|
|
import { friend_listener } from '@/helpers/events'
|
|
import { friends, friend_statuses, add_friend, remove_friend } from '@/helpers/friends'
|
|
import { get_user_many } from '@/helpers/cache'
|
|
import { handleError } from '@/store/notifications.js'
|
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
|
import type { Dayjs } from 'dayjs'
|
|
import dayjs from 'dayjs'
|
|
|
|
const props = defineProps<{
|
|
credentials: unknown | null
|
|
signIn: () => void2
|
|
}>()
|
|
|
|
const userCredentials = computed(() => props.credentials)
|
|
|
|
const search = ref('')
|
|
const manageFriendsModal = ref()
|
|
const friendInvitesModal = ref()
|
|
|
|
const username = ref('')
|
|
const addFriendModal = ref()
|
|
async function addFriendFromModal() {
|
|
addFriendModal.value.hide()
|
|
await add_friend(username.value).catch(handleError)
|
|
username.value = ''
|
|
await loadFriends()
|
|
}
|
|
|
|
const friendOptions = ref()
|
|
async function handleFriendOptions(args) {
|
|
switch (args.option) {
|
|
case 'remove-friend':
|
|
await removeFriend(args.item)
|
|
break
|
|
}
|
|
}
|
|
|
|
async function addFriend(friend: Friend) {
|
|
await add_friend(
|
|
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
|
|
).catch(handleError)
|
|
await loadFriends()
|
|
}
|
|
|
|
async function removeFriend(friend: Friend) {
|
|
await remove_friend(
|
|
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
|
|
).catch(handleError)
|
|
await loadFriends()
|
|
}
|
|
|
|
type Friend = {
|
|
id: string
|
|
friend_id: string | null
|
|
status: string | null
|
|
last_updated: Dayjs | null
|
|
created: Dayjs
|
|
username: string
|
|
accepted: boolean
|
|
online: boolean
|
|
avatar: string
|
|
}
|
|
|
|
const userFriends = ref<Friend[]>([])
|
|
const acceptedFriends = computed(() =>
|
|
userFriends.value
|
|
.filter((x) => x.accepted)
|
|
.toSorted((a, b) => {
|
|
if (a.last_updated === null && b.last_updated === null) {
|
|
return 0 // Both are null, equal in sorting
|
|
}
|
|
if (a.last_updated === null) {
|
|
return 1 // `a` is null, move it after `b`
|
|
}
|
|
if (b.last_updated === null) {
|
|
return -1 // `b` is null, move it after `a`
|
|
}
|
|
// Both are non-null, sort by date
|
|
return b.last_updated.diff(a.last_updated)
|
|
}),
|
|
)
|
|
const pendingFriends = computed(() =>
|
|
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
|
|
)
|
|
|
|
const loading = ref(true)
|
|
async function loadFriends(timeout = false) {
|
|
loading.value = timeout
|
|
|
|
try {
|
|
const friendsList = await friends()
|
|
|
|
if (friendsList.length === 0) {
|
|
userFriends.value = []
|
|
} else {
|
|
const friendStatuses = await friend_statuses()
|
|
const users = await get_user_many(
|
|
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
|
|
)
|
|
|
|
userFriends.value = friendsList.map((friend) => {
|
|
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
|
|
const status = friendStatuses.find(
|
|
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
|
|
)
|
|
return {
|
|
id: friend.id,
|
|
friend_id: friend.friend_id,
|
|
status: status?.profile_name,
|
|
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
|
|
created: dayjs(friend.created),
|
|
avatar: user?.avatar_url,
|
|
username: user?.username,
|
|
online: !!status,
|
|
accepted: friend.accepted,
|
|
}
|
|
})
|
|
}
|
|
|
|
loading.value = false
|
|
} catch (e) {
|
|
console.error('Error loading friends', e)
|
|
if (timeout) {
|
|
setTimeout(() => loadFriends(), 15 * 1000)
|
|
}
|
|
}
|
|
}
|
|
|
|
watch(
|
|
userCredentials,
|
|
() => {
|
|
console.log('watch', userCredentials.value)
|
|
if (userCredentials.value === undefined) {
|
|
userFriends.value = []
|
|
} else if (userCredentials.value === null) {
|
|
userFriends.value = []
|
|
loading.value = false
|
|
} else {
|
|
loadFriends(true)
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
const unlisten = await friend_listener(() => loadFriends())
|
|
onUnmounted(() => {
|
|
unlisten()
|
|
})
|
|
// TODO: Remove friends menu
|
|
</script>
|
|
|
|
<template>
|
|
<NewModal ref="manageFriendsModal" header="Manage friends">
|
|
<p v-if="acceptedFriends.length === 0">You have no friends :C</p>
|
|
<div v-else class="flex flex-col gap-4">
|
|
<input type="text" placeholder="Search friends..." class="w-full" />
|
|
<div
|
|
v-for="friend in acceptedFriends.filter((x) =>
|
|
x.username.toLowerCase().includes(search.value),
|
|
)"
|
|
:key="friend.username"
|
|
class="flex gap-2 items-center min-w-[20rem]"
|
|
>
|
|
<div class="relative">
|
|
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
|
<span
|
|
v-if="friend.online"
|
|
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
|
/>
|
|
</div>
|
|
<div>{{ friend.username }}</div>
|
|
<div class="ml-auto">
|
|
<ButtonStyled>
|
|
<button @click="removeFriend(friend)">
|
|
<XIcon />
|
|
Remove
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</NewModal>
|
|
<NewModal ref="friendInvitesModal" header="View friend requests">
|
|
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
|
|
<div v-else class="flex flex-col gap-4">
|
|
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
|
|
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
|
<div class="flex flex-col gap-2">
|
|
<div>
|
|
<p class="m-0">
|
|
<template v-if="friend.id === userCredentials.user_id">
|
|
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
|
|
</template>
|
|
<template v-else>
|
|
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
|
</template>
|
|
</p>
|
|
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<template v-if="friend.id === userCredentials.user_id">
|
|
<ButtonStyled color="brand">
|
|
<button @click="addFriend(friend)">
|
|
<UserPlusIcon />
|
|
Accept
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled>
|
|
<button @click="removeFriend(friend)">
|
|
<XIcon />
|
|
Ignore
|
|
</button>
|
|
</ButtonStyled>
|
|
</template>
|
|
<template v-else>
|
|
<ButtonStyled>
|
|
<button @click="removeFriend(friend)">
|
|
<XIcon />
|
|
Cancel
|
|
</button>
|
|
</ButtonStyled>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</NewModal>
|
|
<NewModal ref="addFriendModal" header="Add a friend">
|
|
<div class="mb-4">
|
|
<h2 class="m-0 text-xl">Username</h2>
|
|
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
|
|
<input v-model="username" class="mt-2" type="text" placeholder="Enter username..." />
|
|
</div>
|
|
<ButtonStyled color="brand">
|
|
<button class="ml-auto" :disabled="username.length === 0" @click="addFriendFromModal">
|
|
<UserPlusIcon />
|
|
Add friend
|
|
</button>
|
|
</ButtonStyled>
|
|
</NewModal>
|
|
<div class="flex justify-between items-center">
|
|
<h3 class="text-lg m-0">Friends</h3>
|
|
<ButtonStyled type="transparent" circular>
|
|
<OverflowMenu
|
|
:options="[
|
|
{
|
|
id: 'add-friend',
|
|
action: () => addFriendModal.show(),
|
|
},
|
|
{
|
|
id: 'manage-friends',
|
|
action: () => manageFriendsModal.show(),
|
|
shown: acceptedFriends.length > 0,
|
|
},
|
|
{
|
|
id: 'view-requests',
|
|
action: () => friendInvitesModal.show(),
|
|
shown: pendingFriends.length > 0,
|
|
},
|
|
]"
|
|
aria-label="More options"
|
|
>
|
|
<MoreVerticalIcon aria-hidden="true" />
|
|
<template #add-friend>
|
|
<UserPlusIcon aria-hidden="true" />
|
|
Add friend
|
|
</template>
|
|
<template #manage-friends>
|
|
<SettingsIcon aria-hidden="true" />
|
|
Manage friends
|
|
<div
|
|
v-if="acceptedFriends.length > 0"
|
|
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
|
|
>
|
|
{{ acceptedFriends.length }}
|
|
</div>
|
|
</template>
|
|
<template #view-requests>
|
|
<MailIcon aria-hidden="true" />
|
|
View friend requests
|
|
<div
|
|
v-if="pendingFriends.length > 0"
|
|
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
|
|
>
|
|
{{ pendingFriends.length }}
|
|
</div>
|
|
</template>
|
|
</OverflowMenu>
|
|
</ButtonStyled>
|
|
</div>
|
|
<div class="flex flex-col gap-2 mt-2">
|
|
<template v-if="loading">
|
|
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
|
|
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
|
|
<div class="flex flex-col w-full">
|
|
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
|
|
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="acceptedFriends.length === 0">
|
|
<div class="text-sm">
|
|
<div class="mb-2">You have no friends :C</div>
|
|
<div v-if="!userCredentials">
|
|
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
|
|
</div>
|
|
<div v-else>
|
|
Why don't you
|
|
<span class="text-link cursor-pointer" @click="addFriendModal.show()">add one</span>?
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
|
<template #remove-friend> <TrashIcon /> Remove friend </template>
|
|
</ContextMenu>
|
|
<div
|
|
v-for="friend in acceptedFriends.slice(0, 5)"
|
|
:key="friend.username"
|
|
class="flex gap-2 items-center"
|
|
:class="{ grayscale: !friend.online }"
|
|
@contextmenu.prevent.stop="
|
|
(event) =>
|
|
friendOptions.showMenu(event, friend, [
|
|
{
|
|
name: 'remove-friend',
|
|
color: 'danger',
|
|
},
|
|
])
|
|
"
|
|
>
|
|
<div class="relative">
|
|
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
|
<span
|
|
v-if="friend.online"
|
|
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
|
|
{{ friend.username }}
|
|
</span>
|
|
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|