mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-04-03 16:15:36 +00:00
1071 lines
34 KiB
Vue
1071 lines
34 KiB
Vue
<template>
|
|
<vue-advanced-chat
|
|
:theme="theme"
|
|
:current-user-id="currentUser.id"
|
|
:room-id="computedRoomId"
|
|
:template-actions="JSON.stringify(templatesText)"
|
|
:menu-actions="JSON.stringify(menuActions)"
|
|
:text-messages="JSON.stringify(textMessages)"
|
|
:message-actions="messageActions"
|
|
:messages="JSON.stringify(messages)"
|
|
:messages-loaded="messagesLoaded"
|
|
:rooms="JSON.stringify(rooms)"
|
|
:room-actions="JSON.stringify(roomActions)"
|
|
:rooms-loaded="roomsLoaded"
|
|
:loading-rooms="loadingRooms"
|
|
:media-preview-enabled="isSafari ? 'false' : 'true'"
|
|
show-files="true"
|
|
show-audio="true"
|
|
capture-files="true"
|
|
:height="chatHeight"
|
|
:styles="JSON.stringify(computedChatStyle)"
|
|
:show-footer="true"
|
|
:responsive-breakpoint="responsiveBreakpoint"
|
|
:single-room="singleRoom"
|
|
show-reaction-emojis="false"
|
|
@send-message="sendMessage($event.detail[0])"
|
|
@fetch-messages="fetchMessages($event.detail[0])"
|
|
@fetch-more-rooms="fetchRooms"
|
|
@add-room="toggleUserSearch"
|
|
@open-user-tag="redirectToUserProfile($event.detail[0])"
|
|
@open-file="openFile($event.detail[0].file.file)"
|
|
>
|
|
<div v-if="selectedRoom && selectedRoom.roomId" slot="room-options" class="chat-room-options">
|
|
<div v-if="singleRoom" class="ds-flex">
|
|
<div class="ds-flex-item single-chat-bubble" style="align-self: center">
|
|
<os-button
|
|
as="nuxt-link"
|
|
:to="expandChatLink"
|
|
variant="primary"
|
|
appearance="ghost"
|
|
circle
|
|
size="sm"
|
|
:aria-label="$t('chat.expandChat')"
|
|
>
|
|
<template #icon>
|
|
<os-icon :icon="icons.expand" />
|
|
</template>
|
|
</os-button>
|
|
</div>
|
|
<div class="ds-flex-item" style="align-self: center">
|
|
<div class="vac-svg-button vac-room-options">
|
|
<slot name="menu-icon">
|
|
<os-button
|
|
variant="primary"
|
|
appearance="ghost"
|
|
circle
|
|
size="sm"
|
|
:aria-label="$t('chat.closeChat')"
|
|
@click="$emit('close-single-room', true)"
|
|
>
|
|
<template #icon>
|
|
<os-icon :icon="icons.close" />
|
|
</template>
|
|
</os-button>
|
|
</slot>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div slot="room-header-avatar">
|
|
<nuxt-link v-if="roomHeaderLink" :to="roomHeaderLink" class="chat-header-profile-link">
|
|
<profile-avatar
|
|
v-if="selectedRoom && selectedRoom.isGroupRoom"
|
|
:profile="selectedRoom.groupProfile"
|
|
class="vac-avatar-profile"
|
|
size="small"
|
|
/>
|
|
<div
|
|
v-else-if="selectedRoom && selectedRoom.avatar"
|
|
class="vac-avatar"
|
|
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
|
|
/>
|
|
<div v-else-if="selectedRoom" class="vac-avatar">
|
|
<span class="initials">{{ getInitialsName(selectedRoom.roomName) }}</span>
|
|
</div>
|
|
</nuxt-link>
|
|
<template v-else>
|
|
<profile-avatar
|
|
v-if="selectedRoom && selectedRoom.isGroupRoom"
|
|
:profile="selectedRoom.groupProfile"
|
|
class="vac-avatar-profile"
|
|
size="small"
|
|
/>
|
|
<div
|
|
v-else-if="selectedRoom && selectedRoom.avatar"
|
|
class="vac-avatar"
|
|
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
|
|
/>
|
|
<div v-else-if="selectedRoom" class="vac-avatar">
|
|
<span class="initials">{{ getInitialsName(selectedRoom.roomName) }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div slot="room-header-info">
|
|
<div class="vac-room-name vac-text-ellipsis">
|
|
<nuxt-link v-if="roomHeaderLink" :to="roomHeaderLink" class="chat-header-profile-link">
|
|
{{ selectedRoom ? selectedRoom.roomName : '' }}
|
|
</nuxt-link>
|
|
<span v-else>{{ selectedRoom ? selectedRoom.roomName : '' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
|
|
<profile-avatar
|
|
v-if="room.isGroupRoom"
|
|
:profile="room.groupProfile"
|
|
class="vac-avatar-profile"
|
|
size="small"
|
|
/>
|
|
<div
|
|
v-else-if="room.avatar"
|
|
class="vac-avatar"
|
|
:style="{ 'background-image': `url('${room.avatar}')` }"
|
|
/>
|
|
<div v-else class="vac-avatar">
|
|
<span class="initials">{{ getInitialsName(room.roomName) }}</span>
|
|
</div>
|
|
</div>
|
|
</vue-advanced-chat>
|
|
</template>
|
|
|
|
<script>
|
|
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
|
import { iconRegistry } from '~/utils/iconRegistry'
|
|
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
|
|
import locales from '~/locales/index.js'
|
|
import gql from 'graphql-tag'
|
|
import { roomQuery, createGroupRoom, unreadRoomsQuery } from '~/graphql/Rooms'
|
|
import {
|
|
messageQuery,
|
|
createMessageMutation,
|
|
chatMessageAdded,
|
|
chatMessageStatusUpdated,
|
|
markMessagesAsSeen,
|
|
} from '~/graphql/Messages'
|
|
import chatStyle from '~/constants/chat.js'
|
|
import { mapGetters, mapMutations } from 'vuex'
|
|
|
|
export default {
|
|
name: 'Chat',
|
|
components: { OsButton, OsIcon, ProfileAvatar },
|
|
props: {
|
|
theme: {
|
|
type: String,
|
|
default: 'light',
|
|
},
|
|
singleRoom: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
userId: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
groupId: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
menuActions: [],
|
|
messageActions: [],
|
|
templatesText: [],
|
|
roomActions: [],
|
|
responsiveBreakpoint: 600,
|
|
rooms: [],
|
|
roomsLoaded: false,
|
|
roomCursor: null,
|
|
roomPageSize: 10,
|
|
selectedRoom: null,
|
|
activeRoomId: null,
|
|
loadingRooms: true,
|
|
messagesLoaded: false,
|
|
messagePageSize: 20,
|
|
oldestLoadedIndexId: null,
|
|
messages: [],
|
|
unseenMessageIds: new Set(),
|
|
pendingStatusUpdates: {},
|
|
}
|
|
},
|
|
created() {
|
|
this.icons = iconRegistry
|
|
},
|
|
watch: {
|
|
groupId(newGroupId) {
|
|
if (this.singleRoom && newGroupId) {
|
|
this.newGroupRoom(newGroupId)
|
|
}
|
|
},
|
|
userId(newUserId) {
|
|
if (this.singleRoom && newUserId) {
|
|
this.newRoom(newUserId)
|
|
}
|
|
},
|
|
currentLocaleIso() {
|
|
this.messages = this.messages.map((m) => {
|
|
this.formatMessageDate(m)
|
|
return { ...m }
|
|
})
|
|
},
|
|
},
|
|
mounted() {
|
|
if (this.singleRoom && this.groupId) {
|
|
this.newGroupRoom(this.groupId)
|
|
} else if (this.singleRoom && this.userId) {
|
|
this.newRoom(this.userId)
|
|
} else {
|
|
this.fetchRooms()
|
|
}
|
|
|
|
// Subscriptions
|
|
this._subscriptions = []
|
|
|
|
const messageObserver = this.$apollo.subscribe({ query: chatMessageAdded() })
|
|
const messageSub = messageObserver.subscribe({
|
|
next: this.chatMessageAdded,
|
|
error: (error) => this.$toast.error(error),
|
|
})
|
|
this._subscriptions.push(messageSub)
|
|
|
|
const statusObserver = this.$apollo.subscribe({ query: chatMessageStatusUpdated() })
|
|
const statusSub = statusObserver.subscribe({
|
|
next: this.handleMessageStatusUpdated,
|
|
})
|
|
this._subscriptions.push(statusSub)
|
|
},
|
|
beforeDestroy() {
|
|
this._subscriptions?.forEach((s) => s.unsubscribe())
|
|
if (this._intersectionObserver) this._intersectionObserver.disconnect()
|
|
if (this._mutationObserver) this._mutationObserver.disconnect()
|
|
if (this._seenFlushTimer) clearTimeout(this._seenFlushTimer)
|
|
},
|
|
computed: {
|
|
...mapGetters({
|
|
currentUser: 'auth/user',
|
|
}),
|
|
chatHeight() {
|
|
if (this.singleRoom) return 'calc(100dvh - 190px)'
|
|
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
|
|
return '100%'
|
|
}
|
|
return 'calc(100dvh - 190px)'
|
|
},
|
|
computedChatStyle() {
|
|
return chatStyle.STYLE.light
|
|
},
|
|
computedRoomId() {
|
|
return this.activeRoomId || null
|
|
},
|
|
isSafari() {
|
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
|
},
|
|
currentLocaleIso() {
|
|
const code = this.$i18n.locale()
|
|
const locale = locales.find((l) => l.code === code)
|
|
return locale ? locale.iso : 'en-US'
|
|
},
|
|
expandChatLink() {
|
|
if (!this.selectedRoom) return { name: 'chat' }
|
|
const query = {}
|
|
if (this.selectedRoom.isGroupRoom && this.selectedRoom.groupProfile?.id) {
|
|
query.groupId = this.selectedRoom.groupProfile.id
|
|
} else {
|
|
const otherUser = this.selectedRoom.users?.find((u) => u.id !== this.currentUser.id)
|
|
if (otherUser) query.userId = otherUser.id
|
|
}
|
|
return { name: 'chat', query }
|
|
},
|
|
roomHeaderLink() {
|
|
if (!this.selectedRoom) return null
|
|
if (this.selectedRoom.isGroupRoom && this.selectedRoom.groupProfile?.id) {
|
|
const { id, slug } = this.selectedRoom.groupProfile
|
|
return `/groups/${id}/${slug}`
|
|
}
|
|
const otherUser = this.selectedRoom.users?.find((u) => u.id !== this.currentUser.id)
|
|
if (otherUser) {
|
|
const slug = otherUser.name?.toLowerCase().replaceAll(' ', '-')
|
|
return `/profile/${otherUser.id}/${slug}`
|
|
}
|
|
return null
|
|
},
|
|
textMessages() {
|
|
return {
|
|
ROOMS_EMPTY: this.$t('chat.roomsEmpty'),
|
|
ROOM_EMPTY: this.$t('chat.roomEmpty'),
|
|
NEW_MESSAGES: this.$t('chat.newMessages'),
|
|
MESSAGE_DELETED: this.$t('chat.messageDeleted'),
|
|
MESSAGES_EMPTY: this.$t('chat.messagesEmpty'),
|
|
CONVERSATION_STARTED: this.$t('chat.conversationStarted'),
|
|
TYPE_MESSAGE: this.$t('chat.typeMessage'),
|
|
SEARCH: this.$t('chat.search'),
|
|
IS_ONLINE: this.$t('chat.isOnline'),
|
|
LAST_SEEN: this.$t('chat.lastSeen'),
|
|
IS_TYPING: this.$t('chat.isTyping'),
|
|
CANCEL_SELECT_MESSAGE: this.$t('chat.cancelSelectMessage'),
|
|
}
|
|
},
|
|
},
|
|
methods: {
|
|
...mapMutations({
|
|
commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT',
|
|
}),
|
|
|
|
markAsSeen(messageIds) {
|
|
if (!messageIds.length || !this.selectedRoom) return
|
|
const room = this.selectedRoom
|
|
// Update local messages to seen
|
|
const seenIds = new Set(messageIds)
|
|
this.messages = this.messages.map((m) => {
|
|
if (seenIds.has(m.id)) {
|
|
return { ...m, seen: true }
|
|
}
|
|
return m
|
|
})
|
|
// Update room unread count
|
|
const roomIndex = this.rooms.findIndex((r) => r.id === room.id)
|
|
if (roomIndex !== -1) {
|
|
const changedRoom = { ...this.rooms[roomIndex] }
|
|
changedRoom.unreadCount = Math.max(0, changedRoom.unreadCount - messageIds.length)
|
|
this.rooms[roomIndex] = changedRoom
|
|
}
|
|
// Persist to server
|
|
this.$apollo
|
|
.mutate({
|
|
mutation: markMessagesAsSeen(),
|
|
variables: { messageIds },
|
|
})
|
|
.then(() => {
|
|
this.$apollo
|
|
.query({ query: unreadRoomsQuery(), fetchPolicy: 'network-only' })
|
|
.then(({ data: { UnreadRooms } }) => {
|
|
this.commitUnreadRoomCount(UnreadRooms)
|
|
})
|
|
})
|
|
},
|
|
|
|
setupMessageVisibilityTracking() {
|
|
const shadowRoot = this.$el?.shadowRoot
|
|
if (!shadowRoot || this._mutationObserver) return
|
|
const scrollContainer = shadowRoot.querySelector('.vac-container-scroll')
|
|
if (!scrollContainer) return
|
|
|
|
this._seenBatch = []
|
|
this._seenFlushTimer = null
|
|
|
|
// IntersectionObserver: detects when unseen messages become visible
|
|
this._intersectionObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
const msgId = entry.target.dataset.unseenId
|
|
if (msgId && this.unseenMessageIds.has(msgId)) {
|
|
this.unseenMessageIds.delete(msgId)
|
|
this._seenBatch.push(msgId)
|
|
this._intersectionObserver.unobserve(entry.target)
|
|
}
|
|
}
|
|
}
|
|
if (this._seenBatch.length && !this._seenFlushTimer) {
|
|
this._seenFlushTimer = setTimeout(() => {
|
|
const batch = [...this._seenBatch]
|
|
this._seenBatch = []
|
|
this._seenFlushTimer = null
|
|
this.markAsSeen(batch)
|
|
}, 500)
|
|
}
|
|
},
|
|
{ root: scrollContainer, threshold: 0.1 },
|
|
)
|
|
|
|
// MutationObserver: watches for new message elements in shadow DOM
|
|
this._mutationObserver = new MutationObserver(() => {
|
|
this.observeNewUnseenElements()
|
|
})
|
|
this._mutationObserver.observe(scrollContainer, { childList: true, subtree: true })
|
|
},
|
|
|
|
observeNewUnseenElements() {
|
|
if (this.unseenMessageIds.size === 0 || !this._intersectionObserver) return
|
|
const shadowRoot = this.$el?.shadowRoot
|
|
if (!shadowRoot) return
|
|
for (const msgId of this.unseenMessageIds) {
|
|
const msg = this.messages.find((m) => m.id === msgId)
|
|
if (!msg) continue
|
|
const el = shadowRoot.querySelector(`[id="${msg._id}"]`)
|
|
if (el && !el.dataset.unseenId) {
|
|
el.dataset.unseenId = msgId
|
|
this._intersectionObserver.observe(el)
|
|
}
|
|
}
|
|
},
|
|
|
|
mergeMessages(newMessages) {
|
|
// Track unseen incoming messages
|
|
newMessages
|
|
.filter((m) => m.seen === false && m.senderId !== this.currentUser.id)
|
|
.forEach((m) => this.unseenMessageIds.add(m.id))
|
|
// Merge and sort by indexId, using prepareMessage for normalization
|
|
const msgs = []
|
|
;[...this.messages, ...newMessages].forEach((m) => {
|
|
const prepared = m._rawDate ? m : this.prepareMessage(m)
|
|
msgs[prepared.indexId] = prepared
|
|
})
|
|
const filtered = msgs.filter(Boolean)
|
|
this.applyAvatarsOnList(filtered)
|
|
this.messages = filtered
|
|
},
|
|
|
|
prepareMessage(msg) {
|
|
const m = { ...msg }
|
|
m.content = m.content || ''
|
|
if (!m._rawDate) m._rawDate = m.date
|
|
this.formatMessageDate(m)
|
|
m.avatar = m.avatar?.w320 || m.avatar || null
|
|
if (!m._originalAvatar) m._originalAvatar = m.avatar
|
|
return m
|
|
},
|
|
|
|
applyAvatarsOnList(list) {
|
|
// Show avatar only on the last message of each consecutive sender chain
|
|
for (let i = 0; i < list.length; i++) {
|
|
const msg = list[i]
|
|
const next = list[i + 1]
|
|
const isLastInChain = !next || next.senderId !== msg.senderId
|
|
msg.avatar = isLastInChain ? msg._originalAvatar || null : null
|
|
}
|
|
},
|
|
|
|
replaceLocalMessage(localId, serverMsg) {
|
|
const serverId = serverMsg.id || serverMsg._id
|
|
// Store mapping without triggering reactivity
|
|
if (!this._localToServerIds) this._localToServerIds = {}
|
|
this._localToServerIds[serverId] = localId
|
|
|
|
const idx = this.messages.findIndex((m) => m._id === localId)
|
|
if (idx === -1) return
|
|
|
|
const msg = this.messages[idx]
|
|
const needsRender = msg.isUploading || this.pendingStatusUpdates[serverId]
|
|
|
|
// Update file URLs and clear upload state
|
|
if (msg.isUploading) {
|
|
msg.isUploading = false
|
|
if (serverMsg.files?.length) {
|
|
msg.files = serverMsg.files
|
|
}
|
|
}
|
|
|
|
// Apply any queued status updates
|
|
const pending = this.pendingStatusUpdates[serverId]
|
|
if (pending) {
|
|
delete this.pendingStatusUpdates[serverId]
|
|
if (pending.distributed) msg.distributed = true
|
|
if (pending.seen) msg.seen = true
|
|
}
|
|
|
|
if (needsRender) {
|
|
this.messages = [...this.messages]
|
|
}
|
|
},
|
|
|
|
addSocketMessage(msg) {
|
|
const prepared = this.prepareMessage(msg)
|
|
// Avoid duplicates (own message already added locally, or duplicate socket event)
|
|
if (this.messages.some((m) => m._id === prepared._id || m.id === prepared.id)) return
|
|
const updatedMessages = [...this.messages, prepared]
|
|
this.applyAvatarsOnList(updatedMessages)
|
|
this.messages = updatedMessages
|
|
|
|
// Track unseen incoming messages
|
|
if (msg.senderId !== this.currentUser.id && !msg.seen) {
|
|
this.unseenMessageIds.add(msg.id)
|
|
this.$nextTick(() => {
|
|
this.setupMessageVisibilityTracking()
|
|
this.observeNewUnseenElements()
|
|
})
|
|
}
|
|
},
|
|
|
|
scrollRoomsListToTop() {
|
|
const roomsList = this.$el?.shadowRoot?.querySelector('#rooms-list')
|
|
if (roomsList) roomsList.scrollTop = 0
|
|
},
|
|
|
|
formatMessageDate(m) {
|
|
const dateObj = new Date(m._rawDate)
|
|
m.timestamp = dateObj.toLocaleTimeString(this.currentLocaleIso, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
m.date = dateObj.toLocaleDateString(this.currentLocaleIso, {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
},
|
|
|
|
async fetchRooms({ room } = {}) {
|
|
this.roomsLoaded = false
|
|
try {
|
|
const variables = room?.id
|
|
? { id: room.id }
|
|
: { first: this.roomPageSize, before: this.roomCursor }
|
|
|
|
const {
|
|
data: { Room },
|
|
} = await this.$apollo.query({
|
|
query: roomQuery(),
|
|
variables,
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
|
|
const existingIds = new Set(this.rooms.map((r) => r.id))
|
|
const newRooms = Room.filter((r) => !existingIds.has(r.id)).map((r) =>
|
|
this.fixRoomObject(r),
|
|
)
|
|
this.rooms = [...this.rooms, ...newRooms]
|
|
|
|
if (!room?.id && Room.length > 0) {
|
|
// Update cursor to the oldest room's sort date
|
|
const lastRoom = Room[Room.length - 1]
|
|
this.roomCursor = lastRoom.lastMessageAt || lastRoom.createdAt
|
|
}
|
|
|
|
if (Room.length < this.roomPageSize) {
|
|
this.roomsLoaded = true
|
|
}
|
|
|
|
if (this.singleRoom && this.rooms.length > 0) {
|
|
this.selectRoom(this.rooms[0])
|
|
}
|
|
} catch (error) {
|
|
this.rooms = []
|
|
this.$toast.error(error.message)
|
|
}
|
|
// must be set false after initial rooms are loaded and never changed again
|
|
this.loadingRooms = false
|
|
},
|
|
|
|
async fetchMessages({ room, options = {} }) {
|
|
if (this.selectedRoom?.id !== room.id) {
|
|
this.messages = []
|
|
this.oldestLoadedIndexId = null
|
|
this.unseenMessageIds = new Set()
|
|
this.selectedRoom = room
|
|
}
|
|
// Virtual rooms have no messages on the server yet
|
|
if (!room.id || room.id.startsWith('temp-')) {
|
|
// Toggle messagesLoaded to ensure vue-advanced-chat's watcher fires
|
|
this.messagesLoaded = false
|
|
this.$nextTick(() => {
|
|
this.messagesLoaded = true
|
|
})
|
|
return
|
|
}
|
|
this.messagesLoaded = options.refetch ? this.messagesLoaded : false
|
|
const variables = {
|
|
roomId: room.id,
|
|
first: this.messagePageSize,
|
|
}
|
|
if (!options.refetch && this.oldestLoadedIndexId !== null) {
|
|
variables.beforeIndex = this.oldestLoadedIndexId
|
|
}
|
|
try {
|
|
const {
|
|
data: { Message },
|
|
} = await this.$apollo.query({
|
|
query: messageQuery(),
|
|
variables,
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
|
|
this.mergeMessages(Message)
|
|
|
|
// Update cursor to oldest loaded message
|
|
if (Message.length > 0 && !options.refetch) {
|
|
const oldestMsg = Message.reduce(
|
|
(min, m) => (m.indexId < min.indexId ? m : min),
|
|
Message[0],
|
|
)
|
|
if (this.oldestLoadedIndexId === null || oldestMsg.indexId < this.oldestLoadedIndexId) {
|
|
this.oldestLoadedIndexId = oldestMsg.indexId
|
|
}
|
|
}
|
|
if (Message.length < this.messagePageSize) {
|
|
this.messagesLoaded = true
|
|
}
|
|
// Ensure visibility tracking is running
|
|
this.$nextTick(() => this.setupMessageVisibilityTracking())
|
|
} catch (error) {
|
|
this.messages = []
|
|
this.$toast.error(error.message)
|
|
}
|
|
},
|
|
|
|
async chatMessageAdded({ data }) {
|
|
const msg = data.chatMessageAdded
|
|
let roomIndex = this.rooms.findIndex((r) => r.id === msg.room.id)
|
|
if (roomIndex === -1) {
|
|
// Room not in list yet — fetch it specifically
|
|
try {
|
|
const {
|
|
data: { Room },
|
|
} = await this.$apollo.query({
|
|
query: roomQuery(),
|
|
variables: { id: msg.room.id },
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
if (Room?.length) {
|
|
const newRoom = this.fixRoomObject(Room[0])
|
|
this.rooms = [newRoom, ...this.rooms]
|
|
roomIndex = 0
|
|
} else {
|
|
return
|
|
}
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
const changedRoom = { ...this.rooms[roomIndex] }
|
|
changedRoom.lastMessage = {
|
|
...msg,
|
|
content: (msg.content || '').trim().substring(0, 30),
|
|
}
|
|
changedRoom.lastMessageAt = msg.date
|
|
changedRoom.index = new Date().toISOString()
|
|
const isCurrentRoom = msg.room.id === this.selectedRoom?.id
|
|
const isOwnMessage = msg.senderId === this.currentUser.id
|
|
if (!isCurrentRoom && !isOwnMessage) {
|
|
changedRoom.unreadCount++
|
|
}
|
|
// Reassign array to trigger Vue reactivity and vue-advanced-chat re-sort
|
|
this.rooms = [changedRoom, ...this.rooms.filter((r) => r.id !== msg.room.id)]
|
|
// Only add incoming messages to the chat — own messages are handled via mutation response
|
|
if (isCurrentRoom && !isOwnMessage) {
|
|
this.addSocketMessage(msg)
|
|
}
|
|
},
|
|
|
|
handleMessageStatusUpdated({ data }) {
|
|
const { roomId, messageIds, status } = data.chatMessageStatusUpdated
|
|
const affectedIds = new Set(messageIds)
|
|
const statusUpdate = status === 'seen' ? { seen: true } : { distributed: true }
|
|
// Update loaded messages locally
|
|
if (this.selectedRoom?.id === roomId) {
|
|
// Resolve server IDs to local _ids via mapping
|
|
const idMap = this._localToServerIds || {}
|
|
let foundAny = false
|
|
this.messages = this.messages.map((m) => {
|
|
const matchById = affectedIds.has(m.id)
|
|
const matchByMapping = messageIds.some((sid) => idMap[sid] === m._id)
|
|
if (matchById || matchByMapping) {
|
|
foundAny = true
|
|
return { ...m, ...statusUpdate }
|
|
}
|
|
return m
|
|
})
|
|
// Queue status updates for messages not yet replaced by server response
|
|
if (!foundAny) {
|
|
for (const msgId of messageIds) {
|
|
this.pendingStatusUpdates[msgId] = {
|
|
...this.pendingStatusUpdates[msgId],
|
|
...statusUpdate,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Update room preview lastMessage status locally
|
|
const roomIndex = this.rooms.findIndex((r) => r.id === roomId)
|
|
if (roomIndex !== -1) {
|
|
const room = this.rooms[roomIndex]
|
|
if (room.lastMessage && affectedIds.has(room.lastMessage.id)) {
|
|
const changedRoom = {
|
|
...room,
|
|
lastMessage: { ...room.lastMessage, ...statusUpdate },
|
|
}
|
|
this.rooms = [
|
|
...this.rooms.slice(0, roomIndex),
|
|
changedRoom,
|
|
...this.rooms.slice(roomIndex + 1),
|
|
]
|
|
}
|
|
}
|
|
},
|
|
|
|
async sendMessage(messageDetails) {
|
|
const { roomId, content, files } = messageDetails
|
|
const isVirtualRoom = roomId.startsWith('temp-')
|
|
|
|
const hasFiles = files && files.length > 0
|
|
|
|
const filesToUpload = hasFiles
|
|
? files.map((file) => ({
|
|
upload: new File(
|
|
[file.blob],
|
|
// Captured audio already has the right extension in the name
|
|
file.extension ? `${file.name}.${file.extension}` : file.name,
|
|
{ type: file.type },
|
|
),
|
|
name: file.name,
|
|
type: file.type,
|
|
}))
|
|
: null
|
|
|
|
// Immediately add new message
|
|
const localMessage = {
|
|
...messageDetails,
|
|
_id: 'new' + Math.random().toString(36).substring(2, 15),
|
|
seen: false,
|
|
saved: true,
|
|
_rawDate: new Date().toISOString(),
|
|
_originalAvatar:
|
|
this.selectedRoom?.users?.find((u) => u.id === this.currentUser.id)?.avatar || null,
|
|
senderId: this.currentUser.id,
|
|
files:
|
|
messageDetails.files?.map((file) => ({
|
|
...file,
|
|
url: URL.createObjectURL(new Blob([file.blob], { type: file.type })),
|
|
})) ?? [],
|
|
isUploading: true,
|
|
}
|
|
this.formatMessageDate(localMessage)
|
|
const updatedMessages = [...this.messages, localMessage]
|
|
this.applyAvatarsOnList(updatedMessages)
|
|
this.messages = updatedMessages
|
|
|
|
const roomIndex = this.rooms.findIndex((r) => r.id === roomId)
|
|
if (roomIndex !== -1) {
|
|
const changedRoom = { ...this.rooms[roomIndex] }
|
|
changedRoom.lastMessage.content = (content || '').trim().substring(0, 30)
|
|
changedRoom.index = new Date().toISOString()
|
|
this.rooms = [changedRoom, ...this.rooms.filter((r) => r.id !== roomId)]
|
|
this.$nextTick(() => {
|
|
this.scrollRoomsListToTop()
|
|
})
|
|
}
|
|
|
|
try {
|
|
// Build mutation variables — userId for virtual rooms, roomId for existing
|
|
const mutationVariables = { content }
|
|
if (isVirtualRoom) {
|
|
const virtualRoom = this.rooms.find((r) => r.id === roomId)
|
|
mutationVariables.userId = virtualRoom?._virtualUserId || roomId.replace('temp-', '')
|
|
} else {
|
|
mutationVariables.roomId = roomId
|
|
}
|
|
if (filesToUpload && filesToUpload.length > 0) {
|
|
mutationVariables.files = filesToUpload
|
|
}
|
|
|
|
const { data } = await this.$apollo.mutate({
|
|
mutation: createMessageMutation(),
|
|
variables: mutationVariables,
|
|
})
|
|
const createdMessage = data.CreateMessage
|
|
|
|
if (createdMessage) {
|
|
// Replace local message with server response
|
|
this.replaceLocalMessage(localMessage._id, createdMessage)
|
|
}
|
|
|
|
if (isVirtualRoom && createdMessage?.room?.id) {
|
|
// Replace virtual room with real room by fetching the specific room
|
|
const realRoomId = createdMessage.room.id
|
|
this.rooms = this.rooms.filter((r) => r.id !== roomId)
|
|
const {
|
|
data: { Room },
|
|
} = await this.$apollo.query({
|
|
query: roomQuery(),
|
|
variables: { id: realRoomId },
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
if (Room?.length) {
|
|
const realRoom = this.fixRoomObject(Room[0])
|
|
this.bringRoomToTopAndSelect(realRoom)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Remove the optimistic local message so it doesn't linger as a ghost
|
|
this.messages = this.messages.filter((m) => m._id !== localMessage._id)
|
|
this.applyAvatarsOnList(this.messages)
|
|
this.$toast.error(error.message)
|
|
}
|
|
},
|
|
|
|
getInitialsName(fullname) {
|
|
if (!fullname) return
|
|
return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
|
|
},
|
|
|
|
toggleUserSearch() {
|
|
this.$emit('toggle-user-search')
|
|
},
|
|
|
|
fixRoomObject(room) {
|
|
// This fixes the room object which arrives from the backend
|
|
const isGroupRoom = room.isGroupRoom || !!room.group
|
|
const fixedRoom = {
|
|
...room,
|
|
isGroupRoom,
|
|
// For group rooms: provide group profile data for ProfileAvatar component
|
|
groupProfile: isGroupRoom
|
|
? {
|
|
id: room.group?.id,
|
|
slug: room.group?.slug,
|
|
name: room.group?.name || room.roomName,
|
|
avatar: room.group?.avatar,
|
|
}
|
|
: null,
|
|
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
|
|
avatar: room.avatar?.w320 || room.avatar,
|
|
lastMessage: room.lastMessage
|
|
? {
|
|
...room.lastMessage,
|
|
content: (room.lastMessage?.content || '').trim().substring(0, 30),
|
|
}
|
|
: { content: '' },
|
|
users: room.users.map((u) => {
|
|
return { ...u, username: u.name, avatar: u.avatar?.w320 }
|
|
}),
|
|
}
|
|
if (!fixedRoom.avatar) {
|
|
if (isGroupRoom) {
|
|
fixedRoom.avatar = room.group?.avatar?.w320 || room.group?.avatar || null
|
|
} else {
|
|
// as long as we cannot query avatar on CreateRoom
|
|
const otherUser = fixedRoom.users.find((u) => u.id !== this.currentUser.id)
|
|
fixedRoom.avatar = otherUser?.avatar
|
|
}
|
|
}
|
|
return fixedRoom
|
|
},
|
|
|
|
selectRoom(room) {
|
|
this.activeRoomId = room.roomId
|
|
},
|
|
|
|
bringRoomToTopAndSelect(room) {
|
|
room.index = new Date().toISOString()
|
|
this.rooms = [room, ...this.rooms.filter((r) => r.id !== room.id)]
|
|
this.$nextTick(() => this.selectRoom(room))
|
|
},
|
|
|
|
async newRoom(userOrId) {
|
|
// Accept either a user object { id, name } or just a userId string
|
|
const userId = typeof userOrId === 'string' ? userOrId : userOrId.id
|
|
let userName = typeof userOrId === 'string' ? null : userOrId.name
|
|
let userAvatar =
|
|
typeof userOrId === 'string' ? null : userOrId.avatar?.w320 || userOrId.avatar?.url || null
|
|
|
|
// When called with just an ID (e.g. from query params), fetch user profile
|
|
if (typeof userOrId === 'string') {
|
|
try {
|
|
const { data } = await this.$apollo.query({
|
|
query: gql`
|
|
query ($id: ID!) {
|
|
User(id: $id) {
|
|
id
|
|
name
|
|
avatar {
|
|
url
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: { id: userId },
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
const user = data.User?.[0]
|
|
if (user) {
|
|
userName = user.name
|
|
userAvatar = user.avatar?.url || null
|
|
}
|
|
} catch {
|
|
// Fall through with userId as display name
|
|
}
|
|
if (!userName) userName = userId
|
|
}
|
|
|
|
// Check if a DM room with this user already exists locally
|
|
const existingRoom = this.rooms.find(
|
|
(r) => !r.isGroupRoom && r.users.some((u) => u.id === userId),
|
|
)
|
|
if (existingRoom) {
|
|
this.bringRoomToTopAndSelect(existingRoom)
|
|
return
|
|
}
|
|
|
|
// Check if a DM room with this user exists on the server (not yet loaded locally)
|
|
try {
|
|
const {
|
|
data: { Room },
|
|
} = await this.$apollo.query({
|
|
query: roomQuery(),
|
|
variables: { userId },
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
const serverRoom = Room?.[0]
|
|
if (serverRoom) {
|
|
const room = this.fixRoomObject(serverRoom)
|
|
this.bringRoomToTopAndSelect(room)
|
|
return
|
|
}
|
|
} catch {
|
|
// Fall through to virtual room creation
|
|
}
|
|
|
|
// Create a virtual room (no backend call — room is created on first message)
|
|
const virtualRoom = {
|
|
id: `temp-${userId}`,
|
|
roomId: `temp-${userId}`,
|
|
roomName: userName,
|
|
isGroupRoom: false,
|
|
groupProfile: null,
|
|
avatar: userAvatar,
|
|
lastMessageAt: null,
|
|
createdAt: new Date().toISOString(),
|
|
unreadCount: 0,
|
|
index: new Date().toISOString(),
|
|
lastMessage: { content: '' },
|
|
users: [
|
|
{ _id: this.currentUser.id, id: this.currentUser.id, username: this.currentUser.name },
|
|
{ _id: userId, id: userId, username: userName, avatar: userAvatar },
|
|
],
|
|
_virtualUserId: userId,
|
|
}
|
|
this.rooms = [virtualRoom, ...this.rooms]
|
|
this.loadingRooms = false
|
|
this.$nextTick(() => this.selectRoom(virtualRoom))
|
|
},
|
|
|
|
async newGroupRoom(groupId) {
|
|
// Check if the group room already exists locally
|
|
const existingRoom = this.rooms.find((r) => r.isGroupRoom && r.groupProfile?.id === groupId)
|
|
if (existingRoom) {
|
|
this.bringRoomToTopAndSelect(existingRoom)
|
|
return
|
|
}
|
|
|
|
// Check if the group room exists on the server (not yet loaded locally)
|
|
try {
|
|
const {
|
|
data: { Room },
|
|
} = await this.$apollo.query({
|
|
query: roomQuery(),
|
|
variables: { groupId },
|
|
fetchPolicy: 'no-cache',
|
|
})
|
|
if (Room?.length) {
|
|
const room = this.fixRoomObject(Room[0])
|
|
this.bringRoomToTopAndSelect(room)
|
|
return
|
|
}
|
|
} catch {
|
|
// Fall through to creation
|
|
}
|
|
|
|
// Room doesn't exist yet — create it
|
|
try {
|
|
const {
|
|
data: { CreateGroupRoom },
|
|
} = await this.$apollo.mutate({
|
|
mutation: createGroupRoom(),
|
|
variables: { groupId },
|
|
})
|
|
const room = this.fixRoomObject(CreateGroupRoom)
|
|
this.bringRoomToTopAndSelect(room)
|
|
} catch (error) {
|
|
this.$toast.error(error.message)
|
|
}
|
|
},
|
|
|
|
openFile: async function (file) {
|
|
if (!file || !file.url) return
|
|
|
|
/* For videos, this function is called only on Safari.
|
|
We don't want to download video files when clicking on them. */
|
|
if (file.type.startsWith('video/')) return
|
|
|
|
/* To make the browser download the file instead of opening it, it needs to be
|
|
from the same origin or from local blob storage. So we fetch it first
|
|
and then create a download link from blob storage. */
|
|
|
|
const download = await fetch(file.url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': file.type,
|
|
},
|
|
})
|
|
const blob = await download.blob()
|
|
const objectURL = window.URL.createObjectURL(blob)
|
|
const downloadLink = document.createElement('a')
|
|
downloadLink.href = objectURL
|
|
downloadLink.download = `${file.name}.${file.url.split('.').slice(-1).pop()}`
|
|
downloadLink.style.display = 'none'
|
|
document.body.appendChild(downloadLink)
|
|
downloadLink.click()
|
|
document.body.removeChild(downloadLink)
|
|
},
|
|
|
|
redirectToUserProfile({ user }) {
|
|
const userID = user.id
|
|
const userName = user.name.toLowerCase().replaceAll(' ', '-')
|
|
const url = `/profile/${userID}/${userName}`
|
|
this.$router.push({ path: url })
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
<style lang="scss" scoped>
|
|
.vac-avatar {
|
|
background-size: cover;
|
|
background-position: center center;
|
|
background-repeat: no-repeat;
|
|
background-color: $color-primary-dark;
|
|
color: $text-color-primary-inverse;
|
|
height: 42px;
|
|
width: 42px;
|
|
min-height: 42px;
|
|
min-width: 42px;
|
|
margin-right: 15px;
|
|
border-radius: 50%;
|
|
position: relative;
|
|
|
|
> .initials {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
}
|
|
|
|
.vac-avatar-profile {
|
|
margin-right: 15px;
|
|
}
|
|
|
|
.ds-flex-item.single-chat-bubble {
|
|
margin-right: 1em;
|
|
}
|
|
|
|
.chat-header-profile-link {
|
|
color: inherit !important;
|
|
text-decoration: none !important;
|
|
font-size: 16px !important;
|
|
font-weight: 500 !important;
|
|
line-height: 22px !important;
|
|
display: block !important;
|
|
overflow: hidden !important;
|
|
text-overflow: ellipsis !important;
|
|
white-space: nowrap !important;
|
|
|
|
&:hover {
|
|
color: $color-primary !important;
|
|
}
|
|
}
|
|
</style>
|