add more context menu

This commit is contained in:
pa
2026-03-24 11:32:13 +09:00
parent bb5a01ae49
commit 003e0a511e
11 changed files with 662 additions and 392 deletions

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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,

View File

@@ -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",

View File

@@ -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>

View File

@@ -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({

View File

@@ -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')
);

View File

@@ -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>
);
}
},

View File

@@ -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>