mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 22:46:06 +02:00
feat: add social status presets (#252)
This commit is contained in:
@@ -59,7 +59,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="presets.length" class="pb-4 px-16">
|
||||||
|
<span class="text-xs text-muted-foreground mb-2 block">
|
||||||
|
{{ t('dialog.social_status.presets') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(preset, index) in presets"
|
||||||
|
:key="index"
|
||||||
|
class="group inline-flex items-center gap-1.5 pl-2.5 pr-1.5 py-1 rounded-full border bg-background text-xs cursor-pointer hover:bg-accent transition-colors max-w-[200px]"
|
||||||
|
@click="applyPreset(preset)">
|
||||||
|
<i class="x-user-status flex-none" :class="getStatusClass(preset.status)"></i>
|
||||||
|
<span class="truncate">{{ preset.statusDescription || getStatusLabel(preset.status) }}</span>
|
||||||
|
<button
|
||||||
|
class="flex-none size-4 inline-flex items-center justify-center rounded-full opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity"
|
||||||
|
@click.stop="handleDeletePreset(index)">
|
||||||
|
<X class="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
<Button variant="outline" :disabled="socialStatusDialog.loading" @click="handleSavePreset">
|
||||||
|
<Bookmark class="size-4" />
|
||||||
|
{{ t('dialog.social_status.save_preset') }}
|
||||||
|
</Button>
|
||||||
<Button :disabled="socialStatusDialog.loading" @click="saveSocialStatus">
|
<Button :disabled="socialStatusDialog.loading" @click="saveSocialStatus">
|
||||||
{{ t('dialog.social_status.update') }}
|
{{ t('dialog.social_status.update') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -77,7 +102,7 @@
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Check, History } from 'lucide-vue-next';
|
import { Bookmark, Check, History, X } from 'lucide-vue-next';
|
||||||
import { InputGroupButton, InputGroupField } from '@/components/ui/input-group';
|
import { InputGroupButton, InputGroupField } from '@/components/ui/input-group';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
@@ -87,6 +112,7 @@
|
|||||||
|
|
||||||
import { useUserStore } from '../../../stores';
|
import { useUserStore } from '../../../stores';
|
||||||
import { userRequest } from '../../../api';
|
import { userRequest } from '../../../api';
|
||||||
|
import { useStatusPresets } from './composables/useStatusPresets';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { currentUser } = storeToRefs(useUserStore());
|
const { currentUser } = storeToRefs(useUserStore());
|
||||||
@@ -104,6 +130,35 @@
|
|||||||
|
|
||||||
const historyItems = computed(() => props.socialStatusHistoryTable?.data ?? []);
|
const historyItems = computed(() => props.socialStatusHistoryTable?.data ?? []);
|
||||||
|
|
||||||
|
const { presets, addPreset, removePreset, getStatusClass, MAX_PRESETS } = useStatusPresets();
|
||||||
|
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const option = statusOptions.value.find((o) => o.value === status);
|
||||||
|
return option?.label || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset) {
|
||||||
|
const D = props.socialStatusDialog;
|
||||||
|
D.status = preset.status;
|
||||||
|
D.statusDescription = preset.statusDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePreset() {
|
||||||
|
const D = props.socialStatusDialog;
|
||||||
|
const result = await addPreset(D.status, D.statusDescription);
|
||||||
|
if (result === 'ok') {
|
||||||
|
toast.success(t('dialog.social_status.preset_saved'));
|
||||||
|
} else if (result === 'exists') {
|
||||||
|
toast.info(t('dialog.social_status.preset_exists'));
|
||||||
|
} else if (result === 'limit') {
|
||||||
|
toast.warning(t('dialog.social_status.preset_limit', { max: MAX_PRESETS }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeletePreset(index) {
|
||||||
|
await removePreset(index);
|
||||||
|
}
|
||||||
|
|
||||||
const statusOptions = computed(() => {
|
const statusOptions = computed(() => {
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import configRepository from '../../../../services/config';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'VRCX_statusPresets';
|
||||||
|
const MAX_PRESETS = 10;
|
||||||
|
|
||||||
|
const STATUS_CLASS_MAP = {
|
||||||
|
'join me': 'joinme',
|
||||||
|
active: 'online',
|
||||||
|
'ask me': 'askme',
|
||||||
|
busy: 'busy',
|
||||||
|
offline: 'offline'
|
||||||
|
};
|
||||||
|
|
||||||
|
const presets = ref([]);
|
||||||
|
let loadPromise = null;
|
||||||
|
|
||||||
|
export function useStatusPresets() {
|
||||||
|
async function loadPresets() {
|
||||||
|
try {
|
||||||
|
presets.value = await configRepository.getArray(STORAGE_KEY, []);
|
||||||
|
} catch {
|
||||||
|
presets.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPreset(status, statusDescription) {
|
||||||
|
if (presets.value.length >= MAX_PRESETS) {
|
||||||
|
return 'limit';
|
||||||
|
}
|
||||||
|
const exists = presets.value.some(
|
||||||
|
(p) => p.status === status && p.statusDescription === statusDescription
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
return 'exists';
|
||||||
|
}
|
||||||
|
presets.value.push({ status, statusDescription });
|
||||||
|
await configRepository.setArray(STORAGE_KEY, presets.value);
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePreset(index) {
|
||||||
|
presets.value.splice(index, 1);
|
||||||
|
await configRepository.setArray(STORAGE_KEY, presets.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(status) {
|
||||||
|
return STATUS_CLASS_MAP[status] || 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadPromise) {
|
||||||
|
loadPromise = loadPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { presets, loadPresets, addPreset, removePreset, getStatusClass, MAX_PRESETS };
|
||||||
|
}
|
||||||
@@ -1544,7 +1544,12 @@
|
|||||||
"header": "Social Status",
|
"header": "Social Status",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"status_placeholder": "Status",
|
"status_placeholder": "Status",
|
||||||
"update": "Update"
|
"update": "Update",
|
||||||
|
"presets": "Presets",
|
||||||
|
"save_preset": "Save as Preset",
|
||||||
|
"preset_saved": "Preset saved",
|
||||||
|
"preset_exists": "Preset already exists",
|
||||||
|
"preset_limit": "Maximum {max} presets"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"header": "Language",
|
"header": "Language",
|
||||||
|
|||||||
@@ -108,6 +108,21 @@
|
|||||||
</ContextMenuCheckboxItem>
|
</ContextMenuCheckboxItem>
|
||||||
</ContextMenuSubContent>
|
</ContextMenuSubContent>
|
||||||
</ContextMenuSub>
|
</ContextMenuSub>
|
||||||
|
<ContextMenuSub v-if="statusPresets.length">
|
||||||
|
<ContextMenuSubTrigger>
|
||||||
|
{{ t('dialog.social_status.presets') }}
|
||||||
|
</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
v-for="(preset, idx) in statusPresets"
|
||||||
|
:key="idx"
|
||||||
|
class="gap-2"
|
||||||
|
@click="applyStatusPreset(preset)">
|
||||||
|
<i class="x-user-status" :class="presetStatusClass(preset.status)"></i>
|
||||||
|
<span class="truncate max-w-[180px]">{{ getPresetDisplayText(preset) }}</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</template>
|
</template>
|
||||||
@@ -218,6 +233,7 @@
|
|||||||
import FriendItem from './FriendItem.vue';
|
import FriendItem from './FriendItem.vue';
|
||||||
import Location from '../../../components/Location.vue';
|
import Location from '../../../components/Location.vue';
|
||||||
import configRepository from '../../../services/config';
|
import configRepository from '../../../services/config';
|
||||||
|
import { useStatusPresets } from '../../../components/dialogs/UserDialog/composables/useStatusPresets';
|
||||||
|
|
||||||
import '@/styles/status-icon.css';
|
import '@/styles/status-icon.css';
|
||||||
import { showUserDialog } from '../../../coordinators/userCoordinator';
|
import { showUserDialog } from '../../../coordinators/userCoordinator';
|
||||||
@@ -252,6 +268,7 @@
|
|||||||
const { currentUser } = storeToRefs(useUserStore());
|
const { currentUser } = storeToRefs(useUserStore());
|
||||||
const { checkCanInvite, checkCanInviteSelf } = useInviteChecks();
|
const { checkCanInvite, checkCanInviteSelf } = useInviteChecks();
|
||||||
const { userImage, userStatusClass } = useUserDisplay();
|
const { userImage, userStatusClass } = useUserDisplay();
|
||||||
|
const { presets: statusPresets, getStatusClass: presetStatusClass } = useStatusPresets();
|
||||||
|
|
||||||
const isFriendsGroupMe = ref(true);
|
const isFriendsGroupMe = ref(true);
|
||||||
const isVIPFriends = ref(true);
|
const isVIPFriends = ref(true);
|
||||||
@@ -720,6 +737,23 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPresetDisplayText(preset) {
|
||||||
|
if (preset.statusDescription) return preset.statusDescription;
|
||||||
|
const option = statusOptions.value.find((o) => o.value === preset.status);
|
||||||
|
return option?.label || preset.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatusPreset(preset) {
|
||||||
|
userRequest
|
||||||
|
.saveCurrentUser({
|
||||||
|
status: preset.status,
|
||||||
|
statusDescription: preset.statusDescription
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success('Status updated');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const canInviteToMyLocation = computed(() => checkCanInvite(lastLocation.value.location));
|
const canInviteToMyLocation = computed(() => checkCanInvite(lastLocation.value.location));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
configRepository: {
|
configRepository: {
|
||||||
getBool: vi.fn(),
|
getBool: vi.fn(),
|
||||||
setBool: vi.fn()
|
setBool: vi.fn(),
|
||||||
|
getArray: vi.fn(),
|
||||||
|
setArray: vi.fn()
|
||||||
},
|
},
|
||||||
notificationRequest: {
|
notificationRequest: {
|
||||||
sendRequestInvite: vi.fn().mockResolvedValue({}),
|
sendRequestInvite: vi.fn().mockResolvedValue({}),
|
||||||
@@ -251,6 +253,8 @@ describe('FriendsSidebar.vue', () => {
|
|||||||
(_key, defaultValue) => Promise.resolve(defaultValue ?? false)
|
(_key, defaultValue) => Promise.resolve(defaultValue ?? false)
|
||||||
);
|
);
|
||||||
mocks.configRepository.setBool.mockResolvedValue(undefined);
|
mocks.configRepository.setBool.mockResolvedValue(undefined);
|
||||||
|
mocks.configRepository.getArray.mockResolvedValue([]);
|
||||||
|
mocks.configRepository.setArray.mockResolvedValue(undefined);
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user