diff --git a/src/components/dialogs/UserDialog/UserActionDropdown.vue b/src/components/dialogs/UserDialog/UserActionDropdown.vue
index 1a28af24..f26e5bbe 100644
--- a/src/components/dialogs/UserDialog/UserActionDropdown.vue
+++ b/src/components/dialogs/UserDialog/UserActionDropdown.vue
@@ -67,10 +67,16 @@
{{ t('dialog.user.actions.request_invite') }}
+
+
+
{{ t('dialog.user.actions.request_invite_with_message') }}
+
+
+
{{ t('dialog.user.actions.invite') }}
+
+
+
{{ t('dialog.user.actions.invite_with_message') }}
+
+
+
@@ -110,6 +122,9 @@
{{ t('dialog.user.actions.send_friend_request') }}
+
+
+
@@ -218,6 +233,7 @@
import {
Check,
CheckCircle,
+ Clock,
Flag,
LineChart,
Mail,
@@ -248,10 +264,12 @@
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
+ DropdownMenuShortcut,
DropdownMenuTrigger
} from '../../ui/dropdown-menu';
import { useGameStore, useLocationStore, useUserStore } from '../../../stores';
import { useInviteChecks } from '../../../composables/useInviteChecks';
+ import { isActionRecent } from '../../../composables/useRecentActions';
const props = defineProps({
userDialogCommand: {
diff --git a/src/components/dialogs/UserDialog/useUserDialogCommands.js b/src/components/dialogs/UserDialog/useUserDialogCommands.js
index 89fb47b9..99aff712 100644
--- a/src/components/dialogs/UserDialog/useUserDialogCommands.js
+++ b/src/components/dialogs/UserDialog/useUserDialogCommands.js
@@ -10,6 +10,7 @@ import {
} from '../../../api';
import { copyToClipboard, parseLocation } from '../../../shared/utils';
import { database } from '../../../services/database';
+import { recordRecentAction } from '../../../composables/useRecentActions';
/**
* Composable for UserDialog command dispatch.
@@ -254,6 +255,7 @@ export function useUserDialogCommands(
)
.then((args) => {
toast('Request invite sent');
+ recordRecentAction(D().id, 'Request Invite');
return args;
});
},
@@ -272,6 +274,7 @@ export function useUserDialogCommands(
},
D().id
);
+ recordRecentAction(D().id, 'Invite Message');
});
},
'Request Invite Message': () => {
@@ -281,6 +284,7 @@ export function useUserDialogCommands(
},
D().id
);
+ recordRecentAction(D().id, 'Request Invite Message');
},
Invite: () => {
let currentLocation = lastLocation.value.location;
@@ -304,6 +308,7 @@ export function useUserDialogCommands(
)
.then((_args) => {
toast(t('message.invite.sent'));
+ recordRecentAction(D().id, 'Invite');
return _args;
});
});
@@ -463,6 +468,7 @@ export function useUserDialogCommands(
userId
});
handleSendFriendRequest(args);
+ recordRecentAction(userId, 'Send Friend Request');
}
},
'Moderation Unblock': {
diff --git a/src/composables/useRecentActions.js b/src/composables/useRecentActions.js
new file mode 100644
index 00000000..bac3b7c0
--- /dev/null
+++ b/src/composables/useRecentActions.js
@@ -0,0 +1,57 @@
+import { useLocalStorage } from '@vueuse/core';
+
+import { useGeneralSettingsStore } from '../stores/settings/general';
+
+/**
+ * Persisted Map tracking recent invite/request actions.
+ * Key: `${userId}:${actionType}`, Value: timestamp (ms).
+ * Stored in localStorage via @vueuse/core.
+ */
+const recentActions = useLocalStorage('VRCX_recentActions', {});
+
+const TRACKED_ACTIONS = new Set([
+ 'Send Friend Request',
+ 'Request Invite',
+ 'Invite',
+ 'Request Invite Message',
+ 'Invite Message'
+]);
+
+/**
+ * @param {string} userId
+ * @param {string} actionType
+ */
+export function recordRecentAction(userId, actionType) {
+ if (!TRACKED_ACTIONS.has(actionType)) {
+ return;
+ }
+ recentActions.value[`${userId}:${actionType}`] = Date.now();
+}
+
+/**
+ * @param {string} userId
+ * @param {string} actionType
+ * @returns {boolean}
+ */
+export function isActionRecent(userId, actionType) {
+ const generalSettings = useGeneralSettingsStore();
+ if (!generalSettings.recentActionCooldownEnabled) {
+ return false;
+ }
+ const key = `${userId}:${actionType}`;
+ const ts = recentActions.value[key];
+ if (!ts) {
+ return false;
+ }
+ const cooldownMs = generalSettings.recentActionCooldownMinutes * 60 * 1000;
+ if (Date.now() - ts < cooldownMs) {
+ return true;
+ }
+ // Expired, clean up
+ delete recentActions.value[key];
+ return false;
+}
+
+export function clearRecentActions() {
+ recentActions.value = {};
+}
diff --git a/src/localization/en.json b/src/localization/en.json
index 0c3a927d..c900cbb3 100644
--- a/src/localization/en.json
+++ b/src/localization/en.json
@@ -798,7 +798,10 @@
"user_dialog": {
"header": "User Dialog",
"vrchat_notes": "VRChat Notes",
- "vrcx_memos": "VRCX Memos"
+ "vrcx_memos": "VRCX Memos",
+ "recent_action_cooldown": "Recent Action Indicator",
+ "recent_action_cooldown_description": "Show a clock icon on invite/request actions that were recently performed",
+ "recent_action_cooldown_minutes": "Cooldown (minutes)"
},
"user_colors": {
"header": "User Colors",
diff --git a/src/stores/settings/general.js b/src/stores/settings/general.js
index 59bce3d1..05500d13 100644
--- a/src/stores/settings/general.js
+++ b/src/stores/settings/general.js
@@ -42,6 +42,8 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
const autoStateChangeGroups = ref([]);
const autoAcceptInviteRequests = ref('Off');
const autoAcceptInviteGroups = ref([]);
+ const recentActionCooldownEnabled = ref(false);
+ const recentActionCooldownMinutes = ref(60);
async function initGeneralSettings() {
const [
@@ -68,7 +70,9 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
autoStateChangeCompanyDescConfig,
autoStateChangeGroupsStrConfig,
autoAcceptInviteRequestsConfig,
- autoAcceptInviteGroupsStrConfig
+ autoAcceptInviteGroupsStrConfig,
+ recentActionCooldownEnabledConfig,
+ recentActionCooldownMinutesConfig
] = await Promise.all([
configRepository.getBool('VRCX_StartAtWindowsStartup', false),
VRCXStorage.Get('VRCX_StartAsMinimizedState'),
@@ -108,7 +112,9 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
configRepository.getString('VRCX_autoStateChangeCompanyDesc', ''),
configRepository.getString('VRCX_autoStateChangeGroups', '[]'),
configRepository.getString('VRCX_autoAcceptInviteRequests', 'Off'),
- configRepository.getString('VRCX_autoAcceptInviteGroups', '[]')
+ configRepository.getString('VRCX_autoAcceptInviteGroups', '[]'),
+ configRepository.getBool('VRCX_recentActionCooldownEnabled', false),
+ configRepository.getInt('VRCX_recentActionCooldownMinutes', 60)
]);
isStartAtWindowsStartup.value = isStartAtWindowsStartupConfig;
@@ -159,6 +165,8 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
autoAcceptInviteGroups.value = JSON.parse(
autoAcceptInviteGroupsStrConfig
);
+ recentActionCooldownEnabled.value = recentActionCooldownEnabledConfig;
+ recentActionCooldownMinutes.value = recentActionCooldownMinutesConfig;
}
initGeneralSettings();
@@ -411,6 +419,29 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
});
}
+ function setRecentActionCooldownEnabled() {
+ recentActionCooldownEnabled.value =
+ !recentActionCooldownEnabled.value;
+ configRepository.setBool(
+ 'VRCX_recentActionCooldownEnabled',
+ recentActionCooldownEnabled.value
+ );
+ }
+
+ /**
+ * @param {number} value
+ */
+ function setRecentActionCooldownMinutes(value) {
+ const parsed = parseInt(value, 10);
+ recentActionCooldownMinutes.value = Number.isNaN(parsed)
+ ? 60
+ : Math.min(1440, Math.max(1, parsed));
+ configRepository.setInt(
+ 'VRCX_recentActionCooldownMinutes',
+ recentActionCooldownMinutes.value
+ );
+ }
+
return {
isStartAtWindowsStartup,
isStartAsMinimizedState,
@@ -435,6 +466,8 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
autoStateChangeGroups,
autoAcceptInviteRequests,
autoAcceptInviteGroups,
+ recentActionCooldownEnabled,
+ recentActionCooldownMinutes,
setIsStartAtWindowsStartup,
setIsStartAsMinimizedState,
@@ -459,6 +492,8 @@ export const useGeneralSettingsStore = defineStore('GeneralSettings', () => {
setAutoStateChangeGroups,
setAutoAcceptInviteRequests,
setAutoAcceptInviteGroups,
- promptProxySettings
+ promptProxySettings,
+ setRecentActionCooldownEnabled,
+ setRecentActionCooldownMinutes
};
});
diff --git a/src/views/Settings/components/Tabs/AppearanceTab.vue b/src/views/Settings/components/Tabs/AppearanceTab.vue
index 8fcd1610..fc1e56f0 100644
--- a/src/views/Settings/components/Tabs/AppearanceTab.vue
+++ b/src/views/Settings/components/Tabs/AppearanceTab.vue
@@ -264,6 +264,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -335,7 +360,7 @@
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { CheckIcon, ChevronDown } from 'lucide-vue-next';
- import { useAppearanceSettingsStore, useFavoriteStore, useVrStore } from '@/stores';
+ import { useAppearanceSettingsStore, useFavoriteStore, useGeneralSettingsStore, useVrStore } from '@/stores';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import { getLanguageName, languageCodes } from '@/localization';
@@ -355,6 +380,7 @@
const { t } = useI18n();
const appearanceSettingsStore = useAppearanceSettingsStore();
+ const generalSettingsStore = useGeneralSettingsStore();
const { saveOpenVROption, updateVRConfigVars } = useVrStore();
const {
@@ -406,6 +432,16 @@
setAppCjkFontPack
} = appearanceSettingsStore;
+ const {
+ recentActionCooldownEnabled,
+ recentActionCooldownMinutes
+ } = storeToRefs(generalSettingsStore);
+
+ const {
+ setRecentActionCooldownEnabled,
+ setRecentActionCooldownMinutes
+ } = generalSettingsStore;
+
const trustColorEntries = [
{
key: 'untrusted',