fix: 聊天室樂觀更新 + private channel fallback

問題: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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 17:59:10 +08:00
parent 1ba4ca893a
commit 6b3a0816f5
+47 -1
View File
@@ -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()
}
// Fallbackpresence 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}`)