mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
feat: blocking frontend (#6911)
* feat: blocking api interfaces * feat: block action on user pages + shared instance flows * feat: safety settings subpage * feat: interaction source settings * feat: finish social settings * feat: profile settings xplat * fix: prepr * feat: click on friends * default instance -> game options & fix feature flag width * fix: scroll indicators --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -123,45 +123,46 @@ const messages = defineMessages({
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
|
||||
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="`/user/${encodeURIComponent(friend.username)}`"
|
||||
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<ButtonStyled v-if="friend.accepted" circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
{
|
||||
id: 'remove-friend',
|
||||
action: () => removeFriend(friend),
|
||||
@@ -170,10 +171,6 @@ const messages = defineMessages({
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
CoffeeIcon,
|
||||
GameIcon,
|
||||
GaugeIcon,
|
||||
HeartHandshakeIcon,
|
||||
LanguagesIcon,
|
||||
ModrinthIcon,
|
||||
PaintbrushIcon,
|
||||
Settings2Icon,
|
||||
ShieldIcon,
|
||||
ToggleRightIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
@@ -17,13 +19,16 @@ import {
|
||||
defineMessages,
|
||||
ProgressBar,
|
||||
TabbedModal,
|
||||
UnsavedChangesPopup,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
|
||||
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
|
||||
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
|
||||
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
|
||||
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
|
||||
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
|
||||
@@ -32,6 +37,10 @@ import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultI
|
||||
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
|
||||
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import {
|
||||
appSettingsModalContextKey,
|
||||
type UnsavedChangesController,
|
||||
} from '@/providers/app-settings-modal'
|
||||
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
@@ -98,6 +107,18 @@ const tabs = [
|
||||
content: FeatureFlagSettings,
|
||||
developerOnly: true,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.profile,
|
||||
category: tabCategories.account,
|
||||
icon: UserIcon,
|
||||
content: ProfileSettings,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.social,
|
||||
category: tabCategories.account,
|
||||
icon: HeartHandshakeIcon,
|
||||
content: SocialSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.privacy',
|
||||
@@ -110,7 +131,7 @@ const tabs = [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.default-instance-options',
|
||||
defaultMessage: 'Default instance options',
|
||||
defaultMessage: 'Default game options',
|
||||
}),
|
||||
category: tabCategories.instances,
|
||||
icon: GameIcon,
|
||||
@@ -139,12 +160,58 @@ const tabs = [
|
||||
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
|
||||
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
|
||||
const emptyUnsavedChangesState: Record<string, unknown> = {}
|
||||
const originalUnsavedChangesState = computed(
|
||||
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
|
||||
)
|
||||
const modifiedUnsavedChangesState = computed(
|
||||
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
|
||||
)
|
||||
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
|
||||
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
|
||||
|
||||
function canLeaveCurrentTab(): boolean {
|
||||
if (!unsavedChangesController.value?.hasChanges()) return true
|
||||
unsavedChangesPopup.value?.nudge()
|
||||
return false
|
||||
}
|
||||
|
||||
function close(): boolean {
|
||||
return modal.value?.hide() ?? false
|
||||
}
|
||||
|
||||
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
|
||||
unsavedChangesController.value = controller
|
||||
}
|
||||
|
||||
provide(appSettingsModalContextKey, {
|
||||
close,
|
||||
registerUnsavedChangesController,
|
||||
})
|
||||
|
||||
function resetUnsavedChanges(): void {
|
||||
unsavedChangesController.value?.reset()
|
||||
}
|
||||
|
||||
function saveUnsavedChanges(): void {
|
||||
void unsavedChangesController.value?.save()
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
function showProfile(): void {
|
||||
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
|
||||
if (profileTabIndex >= 0) {
|
||||
modal.value?.setTab(profileTabIndex)
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show, showProfile })
|
||||
|
||||
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
|
||||
|
||||
@@ -197,12 +264,30 @@ const messages = defineMessages({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal ref="modal" :tabs="availableTabs" :width="'min(928px, calc(95vw - 10rem))'">
|
||||
<TabbedModal
|
||||
ref="modal"
|
||||
:tabs="availableTabs"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:before-hide="canLeaveCurrentTab"
|
||||
:before-tab-change="canLeaveCurrentTab"
|
||||
:floating-action-bar-shown="hasUnsavedChanges"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #floating-action-bar>
|
||||
<UnsavedChangesPopup
|
||||
ref="unsavedChangesPopup"
|
||||
:original="originalUnsavedChangesState"
|
||||
:modified="modifiedUnsavedChangesState"
|
||||
:saving="savingUnsavedChanges"
|
||||
inline
|
||||
@reset="resetUnsavedChanges"
|
||||
@save="saveUnsavedChanges"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mt-auto text-secondary text-sm">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<AccountProfileSettings
|
||||
ref="profileSettings"
|
||||
:patch-user="patchUser"
|
||||
:change-avatar="changeAvatar"
|
||||
:delete-avatar="deleteAvatar"
|
||||
:get-authenticated-user="getAuthenticatedUser"
|
||||
@profile-link-click="handleProfileLinkClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { AccountProfileSettings, injectAuth } from '@modrinth/ui'
|
||||
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
change_user_avatar,
|
||||
delete_user_avatar,
|
||||
get_user_profile,
|
||||
patch_user,
|
||||
} from '@/helpers/users'
|
||||
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
|
||||
|
||||
const settingsModal = inject(appSettingsModalContextKey, null)
|
||||
const auth = injectAuth()
|
||||
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
settingsModal?.registerUnsavedChangesController({
|
||||
hasChanges: () => profileSettings.value?.hasChanges ?? false,
|
||||
getOriginal: () => profileSettings.value?.originalState ?? {},
|
||||
getModified: () => profileSettings.value?.modifiedState ?? {},
|
||||
isSaving: () => profileSettings.value?.saving ?? false,
|
||||
reset: () => profileSettings.value?.reset(),
|
||||
save: () => profileSettings.value?.save(),
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
settingsModal?.registerUnsavedChangesController(null)
|
||||
})
|
||||
|
||||
function handleProfileLinkClick(event: MouseEvent): void {
|
||||
if (settingsModal && !settingsModal.close()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function patchUser(
|
||||
userId: string,
|
||||
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
|
||||
): Promise<void> {
|
||||
return patch_user(userId, patch)
|
||||
}
|
||||
|
||||
async function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
|
||||
await change_user_avatar(userId, new Uint8Array(await file.arrayBuffer()), extension)
|
||||
}
|
||||
|
||||
function deleteAvatar(userId: string): Promise<void> {
|
||||
return delete_user_avatar(userId)
|
||||
}
|
||||
|
||||
function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
|
||||
const userId = auth.user.value?.id
|
||||
if (!userId) throw new Error('Cannot refresh a signed-out user.')
|
||||
return get_user_profile(userId)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<AccountSocialSettings
|
||||
:get-blocked-users="get_blocked_users"
|
||||
:get-users="getUsers"
|
||||
:unblock-user="unblock_user"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
|
||||
|
||||
import { get_blocked_users, unblock_user } from '@/helpers/users'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
|
||||
return client.labrinth.users_v2.getMultiple(userIds)
|
||||
}
|
||||
</script>
|
||||
@@ -25,7 +25,7 @@ watch(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 min-w-[600px]">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="option in options" :key="option" class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
|
||||
|
||||
+70
-10
@@ -120,8 +120,17 @@
|
||||
:max-height="240"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="reportOnly" class="flex flex-col gap-2">
|
||||
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
|
||||
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
|
||||
<Checkbox
|
||||
v-if="reportOnly"
|
||||
v-model="deleteInstance"
|
||||
:label="formatMessage(messages.deleteInstance)"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="blockTargetUserId"
|
||||
v-model="blockUser"
|
||||
:label="formatMessage(messages.blockUser)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -249,12 +258,14 @@ import {
|
||||
Admonition,
|
||||
AutoLink,
|
||||
Avatar,
|
||||
blockedUsersQueryKey,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
@@ -266,6 +277,7 @@ import {
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
@@ -274,6 +286,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import type { SharedInstanceInstallPreview } from '@/helpers/install'
|
||||
import { create_report } from '@/helpers/reports'
|
||||
import { block_user } from '@/helpers/users'
|
||||
|
||||
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
|
||||
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
|
||||
@@ -284,6 +297,7 @@ type ExternalFileRow = {
|
||||
name: string
|
||||
}
|
||||
type SharedInstanceCreator = {
|
||||
id: string | null
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -300,13 +314,17 @@ type ReportReason = 'malicious' | 'inappropriate' | 'spam'
|
||||
const reportReason = ref<ReportReason>('malicious')
|
||||
const additionalContext = ref('')
|
||||
const deleteInstance = ref(true)
|
||||
const blockUser = ref(true)
|
||||
const blockTargetUserId = ref<string | null>(null)
|
||||
const submitLoading = ref(false)
|
||||
const uploadedImageIDs = ref<string[]>([])
|
||||
const emit = defineEmits<{
|
||||
reported: [deleteInstance: boolean]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { load } = useSharedInstancePreviewContent()
|
||||
const {
|
||||
@@ -365,13 +383,39 @@ async function submitReport() {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const uploadedImages = uploadedImageIDs.value.slice(-10)
|
||||
await create_report({
|
||||
report_type: reportReason.value,
|
||||
item_type: 'shared-instance',
|
||||
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
|
||||
body,
|
||||
uploaded_images: uploadedImages,
|
||||
})
|
||||
const blockTarget = blockUser.value ? blockTargetUserId.value : null
|
||||
const [reportResult, blockResult] = await Promise.allSettled([
|
||||
create_report({
|
||||
report_type: reportReason.value,
|
||||
item_type: 'shared-instance',
|
||||
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
|
||||
body,
|
||||
uploaded_images: uploadedImages,
|
||||
}),
|
||||
blockTarget ? block_user(blockTarget) : Promise.resolve(),
|
||||
])
|
||||
|
||||
if (blockTarget) {
|
||||
if (blockResult.status === 'fulfilled') {
|
||||
blockUser.value = false
|
||||
const authUserId = auth.user.value?.id
|
||||
if (authUserId) {
|
||||
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
|
||||
blockedUsersQueryKey(authUserId),
|
||||
(blockedUsers = []) =>
|
||||
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
|
||||
)
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.userBlocked),
|
||||
})
|
||||
} else {
|
||||
handleError(toError(blockResult.reason))
|
||||
}
|
||||
}
|
||||
|
||||
if (reportResult.status === 'rejected') throw reportResult.reason
|
||||
|
||||
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
|
||||
hide()
|
||||
@@ -426,6 +470,8 @@ function resetReportState() {
|
||||
reportReason.value = 'malicious'
|
||||
additionalContext.value = ''
|
||||
deleteInstance.value = true
|
||||
blockUser.value = true
|
||||
blockTargetUserId.value = null
|
||||
submitLoading.value = false
|
||||
uploadedImageIDs.value = []
|
||||
}
|
||||
@@ -437,12 +483,18 @@ function show(
|
||||
) {
|
||||
resetReportState()
|
||||
creator.value = creatorValue ?? null
|
||||
blockTargetUserId.value = creatorValue?.id ?? null
|
||||
install.value = installValue
|
||||
showPreview(previewValue, event)
|
||||
}
|
||||
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
|
||||
function showReport(
|
||||
previewValue: SharedInstanceInstallPreview,
|
||||
blockTargetUserIdValue?: string | null,
|
||||
event?: MouseEvent,
|
||||
) {
|
||||
resetReportState()
|
||||
creator.value = null
|
||||
blockTargetUserId.value = blockTargetUserIdValue ?? null
|
||||
reportMode.value = true
|
||||
reportOnly.value = true
|
||||
install.value = () => {}
|
||||
@@ -537,6 +589,14 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.delete-instance',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
blockUser: {
|
||||
id: 'app.modal.install-to-play.block-user',
|
||||
defaultMessage: 'Block user',
|
||||
},
|
||||
userBlocked: {
|
||||
id: 'app.modal.install-to-play.user-blocked',
|
||||
defaultMessage: 'User blocked',
|
||||
},
|
||||
unknownFilesWarning: {
|
||||
id: 'app.modal.install-to-play.unknown-files-warning',
|
||||
defaultMessage: 'Unknown files warning',
|
||||
|
||||
+3
@@ -32,6 +32,7 @@ type InstallModal = {
|
||||
}
|
||||
|
||||
type SharedInstanceCreator = {
|
||||
id: string | null
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -190,6 +191,7 @@ export function useSharedInstanceInviteHandler(
|
||||
},
|
||||
invite.invitedByUsername
|
||||
? {
|
||||
id: invite.invitedById,
|
||||
username: invite.invitedByUsername,
|
||||
avatarUrl: invite.invitedByAvatarUrl,
|
||||
}
|
||||
@@ -294,6 +296,7 @@ export function useSharedInstanceInviteHandler(
|
||||
},
|
||||
manager
|
||||
? {
|
||||
id: manager.id,
|
||||
username: manager.username,
|
||||
avatarUrl: manager.avatar_url ?? null,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user