From 911df651f80acfd59b547116f124f5028829f605 Mon Sep 17 00:00:00 2001 From: Tajim Date: Fri, 28 Aug 2026 18:37:28 +0600 Subject: [PATCH 1/2] feat(chat): add client-side @mention autocomplete suggestions for chat participants --- resources/js/pages/Chat/Index.vue | 225 ++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/resources/js/pages/Chat/Index.vue b/resources/js/pages/Chat/Index.vue index 54f33a47..65680d58 100644 --- a/resources/js/pages/Chat/Index.vue +++ b/resources/js/pages/Chat/Index.vue @@ -18,6 +18,7 @@ import { AlertCircle, MoreHorizontal, Radio, + AtSign, } from 'lucide-vue-next'; import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'; import ChatBanModal from '@/components/ChatBanModal.vue'; @@ -172,6 +173,123 @@ const cancelReply = () => { activeReplyTo.value = null; }; +// User Mention Autocomplete State & Logic +const showMentionSuggestions = ref(false); +const mentionQuery = ref(''); +const selectedMentionIndex = ref(0); +const mentionStartPos = ref(-1); + +const availableMentionUsers = computed(() => { + const userMap = new Map(); + const currentId = currentUser.value ? Number(currentUser.value.id) : null; + + // Scan loaded messages from latest to oldest so active participants appear first + for (let i = messages.value.length - 1; i >= 0; i--) { + const u = messages.value[i]?.user; + if (u && u.id && u.username) { + if (currentId && Number(u.id) === currentId) { + continue; + } + if (!userMap.has(Number(u.id))) { + userMap.set(Number(u.id), u); + } + } + } + + return Array.from(userMap.values()); +}); + +const filteredMentionUsers = computed(() => { + const q = mentionQuery.value.toLowerCase().trim(); + if (!q) { + return []; + } + return availableMentionUsers.value + .filter( + (u) => + u.username.toLowerCase().includes(q) || + u.name.toLowerCase().includes(q), + ) + .slice(0, 5); +}); + +const checkMentionTrigger = () => { + if (!messageInputRef.value) { + showMentionSuggestions.value = false; + return; + } + + const input = messageInputRef.value; + const cursorPos = input.selectionStart ?? inputContent.value.length; + const textBeforeCursor = inputContent.value.slice(0, cursorPos); + + const match = /(?:^|\s)@([a-zA-Z0-9_.-]*)$/.exec(textBeforeCursor); + + if (match !== null) { + mentionQuery.value = match[1]; + mentionStartPos.value = cursorPos - match[1].length - 1; + selectedMentionIndex.value = 0; + showMentionSuggestions.value = true; + } else { + showMentionSuggestions.value = false; + } +}; + +const handleInputKeydown = (e: KeyboardEvent) => { + if (!showMentionSuggestions.value) { + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + showMentionSuggestions.value = false; + return; + } + + if (filteredMentionUsers.value.length === 0) { + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + selectedMentionIndex.value = + (selectedMentionIndex.value + 1) % filteredMentionUsers.value.length; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + selectedMentionIndex.value = + (selectedMentionIndex.value - 1 + filteredMentionUsers.value.length) % + filteredMentionUsers.value.length; + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (filteredMentionUsers.value[selectedMentionIndex.value]) { + e.preventDefault(); + insertMention(filteredMentionUsers.value[selectedMentionIndex.value]); + } + } +}; + +const insertMention = (user: ChatUser) => { + if (!messageInputRef.value || mentionStartPos.value < 0) { + return; + } + + const input = messageInputRef.value; + const cursorPos = input.selectionStart ?? inputContent.value.length; + const before = inputContent.value.slice(0, mentionStartPos.value); + const after = inputContent.value.slice(cursorPos); + const mentionText = `@${user.username} `; + + inputContent.value = before + mentionText + after; + showMentionSuggestions.value = false; + + nextTick(() => { + if (messageInputRef.value) { + messageInputRef.value.focus(); + const newCursorPos = before.length + mentionText.length; + messageInputRef.value.setSelectionRange(newCursorPos, newCursorPos); + } + }); +}; + const scrollToMessage = (messageId: number) => { const el = document.getElementById(`chat-msg-${messageId}`); @@ -527,6 +645,7 @@ const sendMessage = async () => { } inputContent.value = ''; + showMentionSuggestions.value = false; activeReplyTo.value = null; if (activeCooldownSeconds.value > 0) { @@ -929,6 +1048,7 @@ const handlePopState = () => { const handleDocumentClick = () => { activeReactionPickerMessageId.value = null; + showMentionSuggestions.value = false; }; onMounted(() => { @@ -1432,6 +1552,107 @@ onUnmounted(() => { class="flex items-center gap-2" >
+ +
+
+ + Mention user +
+ + +
+ +
+ + +
+

+ No recent chatters matched +

+

+ Write full username if not suggested +

+
+ + +
+

+ Start typing to get suggestions... +

+

+ Type a name or username +

+
+
+ { : 'Send a message...' " :maxlength="maxLengthLimit" + @input="checkMentionTrigger" + @click="checkMentionTrigger" + @keyup="checkMentionTrigger" + @keydown="handleInputKeydown" class="w-full rounded-xl border border-slate-200 bg-slate-50/80 px-4 py-2.5 text-xs text-slate-800 placeholder:text-slate-400 focus:border-indigo-500 focus:bg-white focus:outline-none disabled:opacity-60 sm:text-sm dark:border-zinc-700 dark:bg-zinc-800/80 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-indigo-400 dark:focus:bg-zinc-800" /> Date: Fri, 28 Aug 2026 18:45:57 +0600 Subject: [PATCH 2/2] feat(seo): update default og:image to S3 CDN and add OG metadata for Global Chat --- resources/js/pages/Chat/Index.vue | 29 +++++++++++++++++++++++++++-- resources/views/app.blade.php | 14 +++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/resources/js/pages/Chat/Index.vue b/resources/js/pages/Chat/Index.vue index 65680d58..46a63bc4 100644 --- a/resources/js/pages/Chat/Index.vue +++ b/resources/js/pages/Chat/Index.vue @@ -1122,10 +1122,35 @@ onUnmounted(() => {