From 6b3a0816f5ca23c0c285b00fa33673abaee28add Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 28 May 2026 17:59:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=81=8A=E5=A4=A9=E5=AE=A4=E6=A8=82?= =?UTF-8?q?=E8=A7=80=E6=9B=B4=E6=96=B0=20+=20private=20channel=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:presence channel 在另一方加入的瞬間可能短暫抖動, 導致雙方都收不到 MessageSent event,訊息消失需重整。 三項修正: 1. sendText 樂觀更新:訊息送出後立即顯示暫存訊息, 不依賴 WebSocket event 才出現。 2. MessageSent handler deduplication:自己的訊息收到 event 時,替換暫存訊息而非重複 push。 3. Private channel fallback:receiver 的 private channel 收到 notification.created 時,補拉一次訊息歷史, 確保 presence channel 失效時仍能同步。使用 stopListening 避免干擾 notifications store 的訂閱。 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/BookingChat.vue | 48 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/BookingChat.vue b/frontend/src/components/BookingChat.vue index b6ce904..5d6ccaa 100644 --- a/frontend/src/components/BookingChat.vue +++ b/frontend/src/components/BookingChat.vue @@ -4,6 +4,8 @@ import api from '../api/axios' import coachApi from '../api/coachAxios' import echo from '../plugins/echo' import { useNotificationStore } from '../stores/notifications' +import { useAuthStore } from '../stores/auth' +import { useCoachAuthStore } from '../stores/coachAuth' const props = defineProps({ bookingId: { type: Number, required: true }, @@ -21,6 +23,12 @@ const otherUserOnline = ref(false) const channel = ref(null) const notificationStore = useNotificationStore() +const authStore = useAuthStore() +const coachAuthStore = useCoachAuthStore() + +const currentUserId = computed(() => + props.currentUserType === 'provider' ? coachAuthStore.user?.id : authStore.user?.id +) const isConfirmed = computed(() => props.bookingStatus === 'confirmed') const isCompleted = computed(() => props.bookingStatus === 'completed') @@ -91,6 +99,19 @@ async function sendText() { const content = textInput.value.trim() textInput.value = '' isSending.value = true + + const tempId = `_temp_${Date.now()}` + messages.value.push({ + id: tempId, + sender_id: currentUserId.value, + sender_type: props.currentUserType, + type: 'text', + content, + read_at: null, + created_at: new Date().toISOString(), + }) + scrollToBottom() + try { await axiosInstance.value.post(`/bookings/${props.bookingId}/messages`, { type: 'text', @@ -98,6 +119,7 @@ async function sendText() { }) } catch (e) { textInput.value = content + messages.value = messages.value.filter(m => m.id !== tempId) } finally { isSending.value = false } @@ -142,6 +164,16 @@ function subscribeChannel() { if (e.user_type === otherType.value) otherUserOnline.value = e.online }) .listen('.MessageSent', async (e) => { + if (e.sender_type === props.currentUserType) { + // 自己送的訊息:替換樂觀更新的暫存訊息(避免重複) + const tempIdx = messages.value.findIndex( + m => typeof m.id === 'string' && m.id.startsWith('_temp_') && m.content === e.content + ) + if (tempIdx !== -1) { + messages.value[tempIdx] = { id: e.id, sender_id: e.sender_id, sender_type: e.sender_type, type: e.type, content: e.content, read_at: null, created_at: e.created_at } + return + } + } messages.value.push({ id: e.id, sender_id: e.sender_id, @@ -153,7 +185,6 @@ function subscribeChannel() { }) scrollToBottom() if (e.sender_type !== props.currentUserType) { - // 對方傳來的訊息:推瀏覽器通知、刷新 bell badge showBrowserNotification(e) notificationStore.fetchUnreadCount() await markLastRead() @@ -177,17 +208,32 @@ function handleReconnected() { subscribeChannel() } +// Fallback:presence channel 失效時,收到 bell 通知也補拉訊息 +async function handleNotificationFallback() { + const lastId = messages.value[messages.value.length - 1]?.id + if (lastId && typeof lastId === 'string') return // 還在樂觀更新中,不拉 + await loadHistory() +} + onMounted(async () => { await requestBrowserNotificationPermission() await loadHistory() if (isConfirmed.value) { subscribeChannel() echo.connector.pusher.connection.bind('reconnected', handleReconnected) + if (currentUserId.value) { + echo.private(`App.Models.User.${currentUserId.value}`) + .listen('.notification.created', handleNotificationFallback) + } } }) onUnmounted(() => { echo.connector.pusher.connection.unbind('reconnected', handleReconnected) + if (currentUserId.value) { + echo.private(`App.Models.User.${currentUserId.value}`) + .stopListening('.notification.created', handleNotificationFallback) + } if (channel.value) { channel.value.whisper('presence', { user_type: props.currentUserType, online: false }) echo.leave(`booking.${props.bookingId}`)