mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-13 20:03:51 +02:00
add more context menu
This commit is contained in:
@@ -1,42 +1,57 @@
|
||||
<template>
|
||||
<div class="cursor-pointer">
|
||||
<div v-if="!text" class="text-transparent">-</div>
|
||||
<div v-show="text" class="flex items-center">
|
||||
<template v-if="isAgeRestricted">
|
||||
<TooltipWrapper :content="t('dialog.user.info.instance_age_restricted_tooltip')" :delay-duration="300" side="top">
|
||||
<div class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Lock class="size-3.5 shrink-0" />
|
||||
<span>{{ t('dialog.user.info.instance_age_restricted') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="region" :class="['flags', 'mr-1.5', 'shrink-0', region]"></div>
|
||||
<TooltipWrapper :content="tooltipContent" :disabled="tooltipDisabled" :delay-duration="300" side="top">
|
||||
<div
|
||||
:class="locationClasses"
|
||||
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden truncate"
|
||||
@click="handleShowWorldDialog">
|
||||
<Spinner v-if="isTraveling" class="mr-1 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span>{{ text }}</span>
|
||||
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1">{{
|
||||
` · #${instanceName}`
|
||||
}}</span>
|
||||
<span v-if="groupName" class="ml-0.5 cursor-pointer" @click.stop="handleShowGroupDialog">
|
||||
({{ groupName }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div class="cursor-pointer">
|
||||
<div v-if="!text" class="text-transparent">-</div>
|
||||
<div v-show="text" class="flex items-center">
|
||||
<template v-if="isAgeRestricted">
|
||||
<TooltipWrapper :content="t('dialog.user.info.instance_age_restricted_tooltip')" :delay-duration="300" side="top">
|
||||
<div class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Lock class="size-3.5 shrink-0" />
|
||||
<span>{{ t('dialog.user.info.instance_age_restricted') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="region" :class="['flags', 'mr-1.5', 'shrink-0', region]"></div>
|
||||
<TooltipWrapper :content="tooltipContent" :disabled="tooltipDisabled" :delay-duration="300" side="top">
|
||||
<div
|
||||
:class="locationClasses"
|
||||
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden truncate"
|
||||
@click="handleShowWorldDialog">
|
||||
<Spinner v-if="isTraveling" class="mr-1 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span>{{ text }}</span>
|
||||
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1">{{
|
||||
` · #${instanceName}`
|
||||
}}</span>
|
||||
<span v-if="groupName" class="ml-0.5 cursor-pointer" @click.stop="handleShowGroupDialog">
|
||||
({{ groupName }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper v-if="isClosed" :content="closedTooltip" :disabled="disableTooltip">
|
||||
<AlertTriangle class="inline-block ml-2 text-muted-foreground shrink-0" />
|
||||
</TooltipWrapper>
|
||||
<Lock v-if="strict" class="inline-block ml-2 text-muted-foreground shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipWrapper v-if="isClosed" :content="closedTooltip" :disabled="disableTooltip">
|
||||
<AlertTriangle class="inline-block ml-2 text-muted-foreground shrink-0" />
|
||||
</TooltipWrapper>
|
||||
<Lock v-if="strict" class="inline-block ml-2 text-muted-foreground shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent v-if="parsedLocation.isRealInstance && parsedLocation.worldId">
|
||||
<WorldActionMenuItems
|
||||
:can-open-instance-in-game="canOpenInstanceInGame"
|
||||
:show-share="true"
|
||||
:show-previous-instances="true"
|
||||
@view-details="handleShowWorldDialog"
|
||||
@share="handleShareLocation"
|
||||
@new-instance="handleNewInstance"
|
||||
@self-invite="handleNewInstanceSelfInvite"
|
||||
@show-previous-instances="handleShowPreviousInstances" />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -45,18 +60,27 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuTrigger
|
||||
} from './ui/context-menu';
|
||||
|
||||
import {
|
||||
getGroupName,
|
||||
getLocationText,
|
||||
getWorldName,
|
||||
copyToClipboard,
|
||||
parseLocation,
|
||||
resolveRegion,
|
||||
translateAccessType
|
||||
} from '../shared/utils';
|
||||
import { useAppearanceSettingsStore, useInstanceStore, useSearchStore, useWorldStore } from '../stores';
|
||||
import { useAppearanceSettingsStore, useInstanceStore, useInviteStore, useSearchStore, useWorldStore } from '../stores';
|
||||
import { showGroupDialog } from '../coordinators/groupCoordinator';
|
||||
import { showWorldDialog } from '../coordinators/worldCoordinator';
|
||||
import { runNewInstanceSelfInviteFlow } from '../coordinators/inviteCoordinator';
|
||||
import { Spinner } from './ui/spinner';
|
||||
import WorldActionMenuItems from './WorldActionMenuItems.vue';
|
||||
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -66,6 +90,7 @@
|
||||
const { verifyShortName } = useSearchStore();
|
||||
const { cachedInstances } = useInstanceStore();
|
||||
const { lastInstanceApplied } = storeToRefs(useInstanceStore());
|
||||
const { canOpenInstanceInGame } = useInviteStore();
|
||||
const { showInstanceIdInLocation, isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
|
||||
|
||||
const props = defineProps({
|
||||
@@ -98,6 +123,7 @@
|
||||
const strict = ref(false);
|
||||
const ageGate = ref(false);
|
||||
const isTraveling = ref(false);
|
||||
const parsedLocation = ref({ isRealInstance: false, worldId: '', tag: '', shortName: '' });
|
||||
const groupName = ref('');
|
||||
const isClosed = ref(false);
|
||||
const instanceName = ref('');
|
||||
@@ -169,6 +195,7 @@
|
||||
isTraveling.value = true;
|
||||
}
|
||||
const L = parseLocation(instanceId);
|
||||
parsedLocation.value = L;
|
||||
setText(L);
|
||||
instanceName.value = L.instanceName;
|
||||
if (!L.isRealInstance) {
|
||||
@@ -299,4 +326,43 @@
|
||||
}
|
||||
showGroupDialog(L.groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShareLocation() {
|
||||
const L = parsedLocation.value;
|
||||
if (!L.worldId) return;
|
||||
copyToClipboard(
|
||||
`https://vrchat.com/home/world/${L.worldId}`,
|
||||
t('message.world.url_copied')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleNewInstance() {
|
||||
const L = parsedLocation.value;
|
||||
if (!L.worldId) return;
|
||||
showWorldDialog(L.tag, L.shortName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleNewInstanceSelfInvite() {
|
||||
const L = parsedLocation.value;
|
||||
if (!L.worldId) return;
|
||||
runNewInstanceSelfInviteFlow(L.worldId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowPreviousInstances() {
|
||||
const instanceId = currentInstanceId();
|
||||
if (!instanceId) return;
|
||||
showPreviousInstancesInfoDialog(instanceId);
|
||||
}
|
||||
</script>
|
||||
|
||||
165
src/components/UserContextMenu.vue
Normal file
165
src/components/UserContextMenu.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem @click="handleViewDetails">
|
||||
<ExternalLink class="size-4" />
|
||||
{{ t('common.actions.view_details') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem v-if="isOnline" @click="handleRequestInvite">
|
||||
<Mail class="size-4" />
|
||||
{{ t('dialog.user.actions.request_invite') }}
|
||||
<ContextMenuShortcut v-if="showRecentRequestInvite">
|
||||
<Clock class="size-3.5 text-muted-foreground" />
|
||||
</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isGameRunning"
|
||||
:disabled="!canInviteToMyLocation"
|
||||
@click="handleInvite">
|
||||
<MessageSquare class="size-4" />
|
||||
{{ t('dialog.user.actions.invite') }}
|
||||
<ContextMenuShortcut v-if="showRecentInvite">
|
||||
<Clock class="size-3.5 text-muted-foreground" />
|
||||
</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="!currentUser?.isBoopingEnabled" @click="handleSendBoop">
|
||||
<MousePointer class="size-4" />
|
||||
{{ t('dialog.user.actions.send_boop') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="isOnline && hasLocation" />
|
||||
<ContextMenuItem
|
||||
v-if="isOnline && hasLocation"
|
||||
:disabled="!canJoin"
|
||||
@click="handleJoin">
|
||||
<LogIn class="size-4" />
|
||||
{{ t('dialog.user.info.launch_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isOnline && hasLocation"
|
||||
:disabled="!canJoin"
|
||||
@click="handleSelfInvite">
|
||||
<Mail class="size-4" />
|
||||
{{ t('dialog.user.info.self_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<slot name="append" />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Clock, ExternalLink, LogIn, Mail, MessageSquare, MousePointer } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger
|
||||
} from './ui/context-menu';
|
||||
import { isRealInstance, parseLocation } from '../shared/utils';
|
||||
import { useGameStore, useLaunchStore, useLocationStore, useUserStore } from '../stores';
|
||||
import { instanceRequest, notificationRequest, queryRequest } from '../api';
|
||||
import { useInviteChecks } from '../composables/useInviteChecks';
|
||||
import { isActionRecent, recordRecentAction } from '../composables/useRecentActions';
|
||||
|
||||
import { showUserDialog } from '../coordinators/userCoordinator';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showSendBoopDialog } = useUserStore();
|
||||
const launchStore = useLaunchStore();
|
||||
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
|
||||
const { isGameRunning } = storeToRefs(useGameStore());
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
const { checkCanInvite, checkCanInviteSelf } = useInviteChecks();
|
||||
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
state: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const isOnline = computed(() => props.state === 'online');
|
||||
const hasLocation = computed(() => !!props.location && isRealInstance(props.location));
|
||||
const canInviteToMyLocation = computed(() => checkCanInvite(lastLocation.value.location));
|
||||
const canJoin = computed(() => {
|
||||
if (!props.location || !isRealInstance(props.location)) return false;
|
||||
return checkCanInviteSelf(props.location);
|
||||
});
|
||||
|
||||
const showRecentRequestInvite = computed(() => isActionRecent(props.userId, 'Request Invite'));
|
||||
const showRecentInvite = computed(() => isActionRecent(props.userId, 'Invite'));
|
||||
|
||||
function handleViewDetails() {
|
||||
showUserDialog(props.userId);
|
||||
}
|
||||
|
||||
function handleRequestInvite() {
|
||||
notificationRequest.sendRequestInvite({ platform: 'standalonewindows' }, props.userId).then(() => {
|
||||
recordRecentAction(props.userId, 'Request Invite');
|
||||
toast.success(t('message.user.request_invite_sent'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
let currentLocation = lastLocation.value.location;
|
||||
if (currentLocation === 'traveling') {
|
||||
currentLocation = lastLocationDestination.value;
|
||||
}
|
||||
const L = parseLocation(currentLocation);
|
||||
queryRequest.fetch('world.location', { worldId: L.worldId }).then((args) => {
|
||||
notificationRequest
|
||||
.sendInvite(
|
||||
{
|
||||
instanceId: L.tag,
|
||||
worldId: L.tag,
|
||||
worldName: args.ref.name
|
||||
},
|
||||
props.userId
|
||||
)
|
||||
.then(() => {
|
||||
recordRecentAction(props.userId, 'Invite');
|
||||
toast.success(t('message.invite.sent'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleSendBoop() {
|
||||
showSendBoopDialog(props.userId);
|
||||
}
|
||||
|
||||
function handleJoin() {
|
||||
if (!props.location) return;
|
||||
launchStore.showLaunchDialog(props.location);
|
||||
}
|
||||
|
||||
function handleSelfInvite() {
|
||||
if (!props.location) return;
|
||||
const L = parseLocation(props.location);
|
||||
instanceRequest
|
||||
.selfInvite({
|
||||
instanceId: L.instanceId,
|
||||
worldId: L.worldId
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t('message.invite.self_sent'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
112
src/components/WorldActionMenuItems.vue
Normal file
112
src/components/WorldActionMenuItems.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<component :is="itemComponent" v-if="showViewDetails" @click="$emit('view-details')">
|
||||
<ExternalLink class="size-4" />
|
||||
{{ t('common.actions.view_details') }}
|
||||
</component>
|
||||
<component :is="itemComponent" v-if="showShare" @click="$emit('share')">
|
||||
<Share2 class="size-4" />
|
||||
{{ t('dialog.world.actions.share') }}
|
||||
</component>
|
||||
<component :is="separatorComponent" v-if="showPrimarySeparator" />
|
||||
<component :is="itemComponent" v-if="showNewInstance" @click="$emit('new-instance')">
|
||||
<Flag class="size-4" />
|
||||
{{ t('dialog.world.actions.new_instance') }}
|
||||
</component>
|
||||
<component :is="itemComponent" v-if="showSelfInvite" @click="$emit('self-invite')">
|
||||
<MessageSquare class="size-4" />
|
||||
{{ selfInviteLabel }}
|
||||
</component>
|
||||
<component :is="separatorComponent" v-if="showSecondarySeparator" />
|
||||
<component :is="itemComponent" v-if="showPreviousInstances" @click="$emit('show-previous-instances')">
|
||||
<LineChart class="size-4" />
|
||||
{{ t('dialog.world.actions.show_previous_instances') }}
|
||||
</component>
|
||||
<slot name="append" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ExternalLink, Flag, LineChart, MessageSquare, Share2 } from 'lucide-vue-next';
|
||||
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator
|
||||
} from './ui/context-menu';
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator
|
||||
} from './ui/dropdown-menu';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'context'
|
||||
},
|
||||
canOpenInstanceInGame: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showViewDetails: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showShare: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showNewInstance: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSelfInvite: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showPreviousInstances: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'view-details',
|
||||
'share',
|
||||
'new-instance',
|
||||
'self-invite',
|
||||
'show-previous-instances'
|
||||
]);
|
||||
|
||||
const selfInviteLabel = computed(() =>
|
||||
props.canOpenInstanceInGame
|
||||
? t('dialog.world.actions.new_instance_and_open_ingame')
|
||||
: t('dialog.world.actions.new_instance_and_self_invite')
|
||||
);
|
||||
|
||||
const itemComponent = computed(() =>
|
||||
props.variant === 'dropdown' ? DropdownMenuItem : ContextMenuItem
|
||||
);
|
||||
|
||||
const separatorComponent = computed(() =>
|
||||
props.variant === 'dropdown'
|
||||
? DropdownMenuSeparator
|
||||
: ContextMenuSeparator
|
||||
);
|
||||
|
||||
const showPrimarySeparator = computed(
|
||||
() =>
|
||||
(props.showViewDetails || props.showShare) &&
|
||||
(props.showNewInstance || props.showSelfInvite)
|
||||
);
|
||||
|
||||
const showSecondarySeparator = computed(
|
||||
() =>
|
||||
props.showPreviousInstances &&
|
||||
(props.showViewDetails ||
|
||||
props.showShare ||
|
||||
props.showNewInstance ||
|
||||
props.showSelfInvite)
|
||||
);
|
||||
</script>
|
||||
143
src/components/dialogs/UserDialog/UserDialogGroupCard.vue
Normal file
143
src/components/dialogs/UserDialog/UserDialogGroupCard.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-41.75 hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="handleViewDetails">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<Avatar class="size-9">
|
||||
<AvatarImage :src="group.iconUrl" class="object-cover" />
|
||||
<AvatarFallback>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-4.5" v-text="group.name"></span>
|
||||
<span class="truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span>{{ t('dialog.group.members.visibility') }} {{ memberVisibility }}</span>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button class="rounded-full ml-1 shrink-0" size="icon-sm" variant="ghost" @click.stop>
|
||||
<MoreHorizontal class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-52">
|
||||
<DropdownMenuItem @click="handleViewDetails">
|
||||
<ExternalLink class="size-4" />
|
||||
{{ t('common.actions.view_details') }}
|
||||
</DropdownMenuItem>
|
||||
<template v-if="canManage && canManageVisibility">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Eye class="size-4" />
|
||||
{{ t('dialog.group.members.visibility') }}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent side="right" align="start" class="w-52">
|
||||
<DropdownMenuItem @click="setVisibility('visible')">
|
||||
<Eye class="size-4" />
|
||||
<Check v-if="memberVisibility === 'visible'" class="size-4" />
|
||||
{{ t('dialog.group.actions.visibility_everyone') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setVisibility('friends')">
|
||||
<Eye class="size-4" />
|
||||
<Check v-if="memberVisibility === 'friends'" class="size-4" />
|
||||
{{ t('dialog.group.actions.visibility_friends') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setVisibility('hidden')">
|
||||
<Eye class="size-4" />
|
||||
<Check v-if="memberVisibility === 'hidden'" class="size-4" />
|
||||
{{ t('dialog.group.actions.visibility_hidden') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
<template v-if="canManage">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" @click="handleLeaveGroup">
|
||||
<Trash2 class="size-4" />
|
||||
{{ t('dialog.group.actions.leave') }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Tag,
|
||||
Trash2,
|
||||
Users
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import { leaveGroupPrompt, setGroupVisibility, showGroupDialog } from '../../../coordinators/groupCoordinator';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
group: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
canManage: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const memberVisibility = computed(
|
||||
() => props.group?.memberVisibility || props.group?.myMember?.visibility || 'visible'
|
||||
);
|
||||
|
||||
const canManageVisibility = computed(
|
||||
() => props.group?.privacy === 'default'
|
||||
);
|
||||
|
||||
function handleViewDetails() {
|
||||
showGroupDialog(props.group.id);
|
||||
}
|
||||
|
||||
function setVisibility(value) {
|
||||
setGroupVisibility(props.group.id, value);
|
||||
}
|
||||
|
||||
function handleLeaveGroup() {
|
||||
leaveGroupPrompt(props.group.id);
|
||||
}
|
||||
</script>
|
||||
@@ -239,32 +239,11 @@
|
||||
</template>
|
||||
<template v-else-if="groupSearchActive">
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; min-height: 60px">
|
||||
<div
|
||||
<UserDialogGroupCard
|
||||
v-for="group in allFilteredGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<Avatar class="size-9">
|
||||
<AvatarImage :src="group.iconUrl" class="object-cover" />
|
||||
<AvatarFallback>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<div class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:group="group"
|
||||
:can-manage="currentUser.id === userDialog.id" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -277,82 +256,22 @@
|
||||
}}</span
|
||||
>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; min-height: 60px">
|
||||
<div
|
||||
<UserDialogGroupCard
|
||||
v-for="group in userDialog.userGroups.ownGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<Avatar class="size-9">
|
||||
<AvatarImage :src="group.iconUrl" class="object-cover" />
|
||||
<AvatarFallback>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<span class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.memberVisibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
:group="group"
|
||||
:can-manage="currentUser.id === userDialog.id" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="userDialog.userGroups.mutualGroups.length > 0">
|
||||
<span class="text-base font-bold">{{ t('dialog.user.groups.mutual_groups') }}</span>
|
||||
<span class="text-xs ml-1.5">{{ userDialog.userGroups.mutualGroups.length }}</span>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; min-height: 60px">
|
||||
<div
|
||||
<UserDialogGroupCard
|
||||
v-for="group in userDialog.userGroups.mutualGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<Avatar class="size-9">
|
||||
<AvatarImage :src="group.iconUrl" class="object-cover" />
|
||||
<AvatarFallback>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<span class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.memberVisibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
:group="group"
|
||||
:can-manage="currentUser.id === userDialog.id" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="userDialog.userGroups.remainingGroups.length > 0">
|
||||
@@ -370,41 +289,11 @@
|
||||
</template>
|
||||
</span>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; min-height: 60px">
|
||||
<div
|
||||
<UserDialogGroupCard
|
||||
v-for="group in userDialog.userGroups.remainingGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<Avatar class="size-9">
|
||||
<AvatarImage :src="group.iconUrl" class="object-cover" />
|
||||
<AvatarFallback>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<div class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.memberVisibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:group="group"
|
||||
:can-manage="currentUser.id === userDialog.id" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -424,6 +313,7 @@
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import UserDialogGroupCard from './UserDialogGroupCard.vue';
|
||||
import { useAuthStore, useGroupStore, useUiStore, useUserStore } from '../../../stores';
|
||||
import {
|
||||
showGroupDialog,
|
||||
|
||||
@@ -3009,7 +3009,7 @@
|
||||
"releases": {
|
||||
"2026_04_05": {
|
||||
"title": "Explore New Features",
|
||||
"subtitle": "A few useful features you might not notice.",
|
||||
"subtitle": "A few useful features you might not notice",
|
||||
"items": {
|
||||
"quick_search": {
|
||||
"title": "Quick Search",
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
<TableCell class="max-w-0 truncate">
|
||||
<template v-if="item.type === 'GPS'">
|
||||
<MapPin class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
<span class="text-muted-foreground"> → </span>
|
||||
<Location
|
||||
class="inline [&>div]:inline-flex"
|
||||
@@ -59,9 +61,11 @@
|
||||
</template>
|
||||
<template v-else-if="item.type === 'Online'">
|
||||
<i class="x-user-status online mr-1"></i>
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
<template v-if="item.location">
|
||||
<span class="text-muted-foreground"> → </span>
|
||||
<Location
|
||||
@@ -74,35 +78,45 @@
|
||||
</template>
|
||||
<template v-else-if="item.type === 'Offline'">
|
||||
<i class="x-user-status mr-1"></i>
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'Status'">
|
||||
<i class="x-user-status mr-1" :class="statusClass(item.status)"></i>
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
<span class="text-muted-foreground"> {{ item.statusDescription }}</span>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'Avatar'">
|
||||
<Box class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
<span class="text-muted-foreground"> → {{ item.avatarName }}</span>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'Bio'">
|
||||
<Pencil class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
<span class="ml-1 text-muted-foreground">{{ t('dashboard.widget.feed_bio') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
<UserContextMenu :user-id="item.userId" :state="getFriendState(item.userId)" :location="getFriendLocation(item.userId)">
|
||||
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||
item.displayName
|
||||
}}</span>
|
||||
</UserContextMenu>
|
||||
<span class="text-muted-foreground"> {{ item.type }}</span>
|
||||
</template>
|
||||
</TableCell>
|
||||
@@ -124,7 +138,7 @@
|
||||
import { statusClass } from '@/shared/utils/user';
|
||||
import { formatDateFilter } from '@/shared/utils';
|
||||
import { showUserDialog } from '@/coordinators/userCoordinator';
|
||||
import { useFeedStore } from '@/stores';
|
||||
import { useFeedStore, useFriendStore } from '@/stores';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -135,6 +149,7 @@
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Location from '@/components/Location.vue';
|
||||
import UserContextMenu from '@/components/UserContextMenu.vue';
|
||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||
import WidgetHeader from './WidgetHeader.vue';
|
||||
import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table';
|
||||
@@ -154,6 +169,7 @@
|
||||
|
||||
const { t } = useI18n();
|
||||
const feedStore = useFeedStore();
|
||||
const friendStore = useFriendStore();
|
||||
const listRef = ref(null);
|
||||
|
||||
const activeFilters = computed(() => {
|
||||
@@ -213,5 +229,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getFriendState(userId) {
|
||||
const friend = friendStore.friends.get(userId);
|
||||
return friend?.state ?? '';
|
||||
}
|
||||
|
||||
function getFriendLocation(userId) {
|
||||
const friend = friendStore.friends.get(userId);
|
||||
return friend?.ref?.location ?? '';
|
||||
}
|
||||
|
||||
defineExpose({ FEED_TYPES });
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
<template>
|
||||
<template v-if="favorite.ref">
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<UserContextMenu
|
||||
:user-id="favorite.id"
|
||||
:state="favorite.ref.state"
|
||||
:location="favorite.ref.location">
|
||||
<template #append>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="showFavoriteDialog('friend', favorite.id)">
|
||||
{{ t('view.favorite.edit_favorite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" @click="handleDeleteFavorite">
|
||||
{{ deleteMenuLabel }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
<Item
|
||||
variant="outline"
|
||||
class="favorites-item cursor-pointer hover:bg-muted x-hover-list"
|
||||
@@ -77,40 +88,7 @@
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Item>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem @click="handleOpenProfile">{{ t('common.actions.view_details') }}</ContextMenuItem>
|
||||
<ContextMenuItem v-if="favorite.ref.state === 'online'" @click="friendRequestInvite">
|
||||
{{ t('dialog.user.actions.request_invite') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="isGameRunning" :disabled="!canInviteToMyLocation" @click="friendInvite">
|
||||
{{ t('dialog.user.actions.invite') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="!currentUser?.isBoopingEnabled" @click="friendSendBoop">
|
||||
{{ t('dialog.user.actions.send_boop') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="favorite.ref.state === 'online' && hasFriendLocation" />
|
||||
<ContextMenuItem
|
||||
v-if="favorite.ref.state === 'online' && hasFriendLocation"
|
||||
:disabled="!canJoinFriend"
|
||||
@click="friendJoin">
|
||||
{{ t('dialog.user.info.launch_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="favorite.ref.state === 'online' && hasFriendLocation"
|
||||
:disabled="!canJoinFriend"
|
||||
@click="friendInviteSelf">
|
||||
{{ t('dialog.user.info.self_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="showFavoriteDialog('friend', favorite.id)">
|
||||
{{ t('view.favorite.edit_favorite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" @click="handleDeleteFavorite">
|
||||
{{ deleteMenuLabel }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</UserContextMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Item variant="outline" class="favorites-item hover:bg-muted x-hover-list" :style="itemStyle">
|
||||
@@ -143,11 +121,8 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
ContextMenuSeparator
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -172,6 +147,7 @@
|
||||
import { useUserDisplay } from '../../../composables/useUserDisplay';
|
||||
|
||||
import Location from '../../../components/Location.vue';
|
||||
import UserContextMenu from '../../../components/UserContextMenu.vue';
|
||||
|
||||
const { userImage } = useUserDisplay();
|
||||
const props = defineProps({
|
||||
|
||||
@@ -43,37 +43,42 @@
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleViewDetails">
|
||||
{{ t('common.actions.view_details') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleNewInstance">
|
||||
{{ t('dialog.world.actions.new_instance') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSelfInvite">
|
||||
{{ inviteOrLaunchText }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="showFavoriteDialog('world', favorite.id)">
|
||||
{{ t('view.favorite.edit_favorite_tooltip') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" @click="handleDeleteFavorite">
|
||||
{{ deleteMenuLabel }}
|
||||
</DropdownMenuItem>
|
||||
<WorldActionMenuItems
|
||||
variant="dropdown"
|
||||
:can-open-instance-in-game="canOpenInstanceInGame"
|
||||
@view-details="handleViewDetails"
|
||||
@new-instance="handleNewInstance"
|
||||
@self-invite="handleSelfInvite">
|
||||
<template #append>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="showFavoriteDialog('world', favorite.id)">
|
||||
{{ t('view.favorite.edit_favorite_tooltip') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" @click="handleDeleteFavorite">
|
||||
{{ deleteMenuLabel }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</WorldActionMenuItems>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Item>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem @click="handleViewDetails">{{ t('common.actions.view_details') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="handleNewInstance">{{ t('dialog.world.actions.new_instance') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="handleSelfInvite">{{ inviteOrLaunchText }}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="showFavoriteDialog('world', favorite.id)">
|
||||
{{ t('view.favorite.edit_favorite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" @click="handleDeleteFavorite">
|
||||
{{ deleteMenuLabel }}
|
||||
</ContextMenuItem>
|
||||
<WorldActionMenuItems
|
||||
:can-open-instance-in-game="canOpenInstanceInGame"
|
||||
@view-details="handleViewDetails"
|
||||
@new-instance="handleNewInstance"
|
||||
@self-invite="handleSelfInvite">
|
||||
<template #append>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="showFavoriteDialog('world', favorite.id)">
|
||||
{{ t('view.favorite.edit_favorite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" @click="handleDeleteFavorite">
|
||||
{{ deleteMenuLabel }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</WorldActionMenuItems>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
@@ -102,6 +107,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { favoriteRequest } from '../../../api';
|
||||
import WorldActionMenuItems from '../../../components/WorldActionMenuItems.vue';
|
||||
import { removeLocalWorldFavorite } from '../../../coordinators/favoriteCoordinator';
|
||||
import { runNewInstanceSelfInviteFlow as newInstanceSelfInvite } from '../../../coordinators/inviteCoordinator';
|
||||
import { showWorldDialog } from '../../../coordinators/worldCoordinator';
|
||||
@@ -146,12 +152,6 @@
|
||||
return url || localFavRef.value?.thumbnailImageUrl;
|
||||
});
|
||||
|
||||
const inviteOrLaunchText = computed(() => {
|
||||
return canOpenInstanceInGame
|
||||
? t('dialog.world.actions.new_instance_and_open_ingame')
|
||||
: t('dialog.world.actions.new_instance_and_self_invite');
|
||||
});
|
||||
|
||||
const deleteMenuLabel = computed(() =>
|
||||
props.isLocalFavorite ? t('view.favorite.delete_tooltip') : t('view.favorite.unfavorite_tooltip')
|
||||
);
|
||||
|
||||
@@ -16,10 +16,19 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
import { formatDateFilter, statusClass, timeToText } from '../../shared/utils';
|
||||
import { i18n } from '../../plugins/i18n';
|
||||
import { useGalleryStore } from '../../stores';
|
||||
import { useGalleryStore, useFriendStore } from '../../stores';
|
||||
import { showUserDialog } from '../../coordinators/userCoordinator';
|
||||
import UserContextMenu from '../../components/UserContextMenu.vue';
|
||||
|
||||
const { t } = i18n.global;
|
||||
let friendStore;
|
||||
|
||||
const getFriendStore = () => {
|
||||
if (!friendStore) {
|
||||
friendStore = useFriendStore();
|
||||
}
|
||||
return friendStore;
|
||||
};
|
||||
|
||||
const expandedRow = ({ row }) => {
|
||||
const original = row.original;
|
||||
@@ -277,15 +286,21 @@ export const columns = [
|
||||
header: () => t('table.feed.user'),
|
||||
meta: { label: () => t('table.feed.user') },
|
||||
cell: ({ row }) => {
|
||||
|
||||
const original = row.original;
|
||||
const friend = getFriendStore().friends.get(original.userId);
|
||||
return (
|
||||
<span
|
||||
class="cursor-pointer pr-2.5"
|
||||
onClick={() => showUserDialog(original.userId)}
|
||||
<UserContextMenu
|
||||
userId={original.userId}
|
||||
state={friend?.state ?? ''}
|
||||
location={friend?.ref?.location ?? ''}
|
||||
>
|
||||
{original.displayName}
|
||||
</span>
|
||||
<span
|
||||
class="cursor-pointer pr-2.5"
|
||||
onClick={() => showUserDialog(original.userId)}
|
||||
>
|
||||
{original.displayName}
|
||||
</span>
|
||||
</UserContextMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<UserContextMenu
|
||||
:user-id="friend.id"
|
||||
:state="friend.state"
|
||||
:location="friend.ref?.location">
|
||||
<Card
|
||||
class="friend-card x-hover-card hover:bg-muted relative"
|
||||
:style="cardStyle"
|
||||
@@ -43,66 +45,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem v-if="friend.state === 'online'" @click="friendRequestInvite">
|
||||
{{ t('dialog.user.actions.request_invite') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="isGameRunning" :disabled="!canInviteToMyLocation" @click="friendInvite">
|
||||
{{ t('dialog.user.actions.invite') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="!currentUser?.isBoopingEnabled" @click="friendSendBoop">
|
||||
{{ t('dialog.user.actions.send_boop') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="friend.state === 'online' && hasFriendLocation" />
|
||||
<ContextMenuItem
|
||||
v-if="friend.state === 'online' && hasFriendLocation"
|
||||
:disabled="!canJoinFriend"
|
||||
@click="friendJoin">
|
||||
{{ t('dialog.user.info.launch_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="friend.state === 'online' && hasFriendLocation"
|
||||
:disabled="!canJoinFriend"
|
||||
@click="friendInviteSelf">
|
||||
{{ t('dialog.user.info.self_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</UserContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Pencil, User } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { isRealInstance, parseLocation } from '../../../shared/utils';
|
||||
import { useGameStore, useLaunchStore, useLocationStore, useUserStore } from '../../../stores';
|
||||
import { instanceRequest, notificationRequest, queryRequest } from '../../../api';
|
||||
import { useInviteChecks } from '../../../composables/useInviteChecks';
|
||||
import { useUserDisplay } from '../../../composables/useUserDisplay';
|
||||
|
||||
import Location from '../../../components/Location.vue';
|
||||
import UserContextMenu from '../../../components/UserContextMenu.vue';
|
||||
import { showUserDialog } from '../../../coordinators/userCoordinator';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showSendBoopDialog } = useUserStore();
|
||||
const launchStore = useLaunchStore();
|
||||
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
|
||||
const { isGameRunning } = storeToRefs(useGameStore());
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
const { checkCanInvite, checkCanInviteSelf } = useInviteChecks();
|
||||
const { userImage, userStatusClass } = useUserDisplay();
|
||||
|
||||
const props = defineProps({
|
||||
@@ -170,86 +127,6 @@
|
||||
|
||||
return 'friend-card__status-dot--hidden';
|
||||
});
|
||||
|
||||
const canInviteToMyLocation = computed(() => checkCanInvite(lastLocation.value.location));
|
||||
|
||||
const hasFriendLocation = computed(() => {
|
||||
const loc = props.friend.ref?.location;
|
||||
return !!loc && isRealInstance(loc);
|
||||
});
|
||||
|
||||
const canJoinFriend = computed(() => {
|
||||
const loc = props.friend.ref?.location;
|
||||
if (!loc || !isRealInstance(loc)) return false;
|
||||
return checkCanInviteSelf(loc);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function friendRequestInvite() {
|
||||
notificationRequest.sendRequestInvite({ platform: 'standalonewindows' }, props.friend.id).then(() => {
|
||||
toast.success('Request invite sent');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function friendInvite() {
|
||||
let currentLocation = lastLocation.value.location;
|
||||
if (currentLocation === 'traveling') {
|
||||
currentLocation = lastLocationDestination.value;
|
||||
}
|
||||
const L = parseLocation(currentLocation);
|
||||
queryRequest.fetch('world.location', { worldId: L.worldId }).then((args) => {
|
||||
notificationRequest
|
||||
.sendInvite(
|
||||
{
|
||||
instanceId: L.tag,
|
||||
worldId: L.tag,
|
||||
worldName: args.ref.name
|
||||
},
|
||||
props.friend.id
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(t('message.invite.sent'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function friendSendBoop() {
|
||||
showSendBoopDialog(props.friend.id);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function friendJoin() {
|
||||
const loc = props.friend.ref?.location;
|
||||
if (!loc) return;
|
||||
launchStore.showLaunchDialog(loc);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function friendInviteSelf() {
|
||||
const loc = props.friend.ref?.location;
|
||||
if (!loc) return;
|
||||
const L = parseLocation(loc);
|
||||
instanceRequest
|
||||
.selfInvite({
|
||||
instanceId: L.instanceId,
|
||||
worldId: L.worldId
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t('message.invite.self_sent'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user