feat: add social status presets (#252)

This commit is contained in:
pa
2026-03-16 12:19:54 +09:00
parent dcec53cdc3
commit ed1db05d94
5 changed files with 157 additions and 3 deletions

View File

@@ -59,7 +59,32 @@
</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>
<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">
{{ t('dialog.social_status.update') }}
</Button>
@@ -77,7 +102,7 @@
DropdownMenuItem,
DropdownMenuTrigger
} 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 { Button } from '@/components/ui/button';
import { computed } from 'vue';
@@ -87,6 +112,7 @@
import { useUserStore } from '../../../stores';
import { userRequest } from '../../../api';
import { useStatusPresets } from './composables/useStatusPresets';
const { t } = useI18n();
const { currentUser } = storeToRefs(useUserStore());
@@ -104,6 +130,35 @@
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 options = [
{

View File

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

View File

@@ -1544,7 +1544,12 @@
"header": "Social Status",
"history": "History",
"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": {
"header": "Language",

View File

@@ -108,6 +108,21 @@
</ContextMenuCheckboxItem>
</ContextMenuSubContent>
</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>
</ContextMenu>
</template>
@@ -218,6 +233,7 @@
import FriendItem from './FriendItem.vue';
import Location from '../../../components/Location.vue';
import configRepository from '../../../services/config';
import { useStatusPresets } from '../../../components/dialogs/UserDialog/composables/useStatusPresets';
import '@/styles/status-icon.css';
import { showUserDialog } from '../../../coordinators/userCoordinator';
@@ -252,6 +268,7 @@
const { currentUser } = storeToRefs(useUserStore());
const { checkCanInvite, checkCanInviteSelf } = useInviteChecks();
const { userImage, userStatusClass } = useUserDisplay();
const { presets: statusPresets, getStatusClass: presetStatusClass } = useStatusPresets();
const isFriendsGroupMe = 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));
/**

View File

@@ -60,7 +60,9 @@ const mocks = vi.hoisted(() => ({
},
configRepository: {
getBool: vi.fn(),
setBool: vi.fn()
setBool: vi.fn(),
getArray: vi.fn(),
setArray: vi.fn()
},
notificationRequest: {
sendRequestInvite: vi.fn().mockResolvedValue({}),
@@ -251,6 +253,8 @@ describe('FriendsSidebar.vue', () => {
(_key, defaultValue) => Promise.resolve(defaultValue ?? false)
);
mocks.configRepository.setBool.mockResolvedValue(undefined);
mocks.configRepository.getArray.mockResolvedValue([]);
mocks.configRepository.setArray.mockResolvedValue(undefined);
vi.clearAllMocks();
});