2026-04-04 03:22:20 +02:00

1109 lines
37 KiB
Vue

<template>
<vue-advanced-chat
:theme="theme"
:current-user-id="currentUser.id"
:room-id="activeRoomId"
:template-actions="EMPTY_ACTIONS"
:menu-actions="EMPTY_ACTIONS"
:text-messages="JSON.stringify(textMessages)"
:message-actions="EMPTY_ACTIONS"
:messages="JSON.stringify(messages)"
:messages-loaded="messagesLoaded"
:rooms="JSON.stringify(rooms)"
:room-actions="EMPTY_ACTIONS"
: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="600"
:single-room="singleRoom"
show-reaction-emojis="false"
custom-search-room-enabled="true"
@send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])"
@fetch-more-rooms="fetchMoreRooms"
@search-room="searchRooms($event.detail[0])"
@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">
<component :is="roomHeaderLink ? 'nuxt-link' : 'span'" :to="roomHeaderLink" class="chat-header-profile-link">
<profile-avatar
v-if="selectedRoom"
:profile="selectedRoomProfile"
class="vac-avatar-profile"
/>
</component>
</div>
<div slot="room-header-info">
<div class="vac-room-name vac-text-ellipsis">
<component :is="roomHeaderLink ? 'nuxt-link' : 'span'" :to="roomHeaderLink" class="chat-header-profile-link">
<os-icon v-if="selectedRoom && selectedRoom.isGroupRoom" :icon="icons.group" class="room-group-icon" />
{{ selectedRoom ? selectedRoom.roomName : '' }}
</component>
</div>
</div>
<div v-for="room in rooms" v-if="room.isGroupRoom" :slot="'room-list-info_' + room.roomId" :key="'info-' + room.id">
<div class="vac-room-name vac-text-ellipsis room-name-with-icon">
<os-icon :icon="icons.group" class="room-group-icon" />
{{ room.roomName }}
</div>
</div>
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
<profile-avatar
:profile="room.isGroupRoom ? room.groupProfile : room.userProfile"
class="vac-avatar-profile"
/>
</div>
<template v-for="msg in messages">
<profile-avatar
v-if="msg.avatar"
:slot="'message-avatar_' + msg._id"
:key="'avatar-' + msg._id"
:profile="messageUserProfile(msg.senderId)"
class="vac-message-avatar"
style="align-self: flex-end; margin: 0 0 2px; cursor: pointer;"
@click.native="navigateToUserProfile(msg.senderId)"
/>
</template>
</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 { roomQuery, createGroupRoom, unreadRoomsQuery, userProfileQuery } from '~/graphql/Rooms'
import {
messageQuery,
createMessageMutation,
chatMessageAdded,
chatMessageStatusUpdated,
markMessagesAsSeen,
} from '~/graphql/Messages'
import chatStyle from '~/constants/chat.js'
import { mapGetters, mapMutations } from 'vuex'
const EMPTY_ACTIONS = JSON.stringify([])
const ROOM_PAGE_SIZE = 10
const MESSAGE_PAGE_SIZE = 20
const RESPONSIVE_BREAKPOINT = 600
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 {
EMPTY_ACTIONS,
rooms: [],
roomsLoaded: false,
roomCursor: null,
roomSearch: '',
roomObserverDirty: false,
selectedRoom: null,
activeRoomId: null,
loadingRooms: true,
messagesLoaded: false,
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._roomLoaderObserver) this._roomLoaderObserver.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)'
return '100%'
},
computedChatStyle() {
return chatStyle.STYLE.light
},
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 }
},
selectedRoomProfile() {
if (!this.selectedRoom) return null
return this.selectedRoom.isGroupRoom ? this.selectedRoom.groupProfile : this.selectedRoom.userProfile
},
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',
}),
buildLastMessage(msg) {
const content = (msg.content || '').trim()
let preview = content ? content.substring(0, 30) : ''
if (!preview && msg.files?.length) {
const f = msg.files[0]
if (!f.type?.startsWith('audio/') && !f.audio) {
preview = `\uD83D\uDCCE ${f.name || ''}`
}
}
return { ...msg, content: preview, files: msg.files }
},
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.$set(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
},
messageUserProfile(senderId) {
const room = this.rooms.find((r) => r.id === this.selectedRoom?.id)
const profile = room?._userProfiles?.[senderId]
if (profile) return profile
const user = this.selectedRoom?.users?.find((u) => u._id === senderId || u.id === senderId)
return user ? { id: user.id, name: user.username || user.name } : { name: senderId }
},
initialsAvatarUrl(name) {
const initials = (name || '?').match(/\b\w/g)?.join('').substring(0, 2).toUpperCase() || '?'
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56"><circle cx="28" cy="28" r="28" fill="rgb(25,122,49)"/><text x="50%25" y="50%25" dominant-baseline="central" text-anchor="middle" fill="white" font-family="sans-serif" font-size="22" font-weight="500">${initials}</text></svg>`
return `data:image/svg+xml,${svg}`
},
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 || this.initialsAvatarUrl(m.username)
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, search, replace } = {}) {
this.roomsLoaded = false
try {
const variables = room?.id
? { id: room.id }
: { first: ROOM_PAGE_SIZE, before: this.roomCursor, ...(search && { search }) }
const {
data: { Room },
} = await this.$apollo.query({
query: roomQuery(),
variables,
fetchPolicy: 'no-cache',
})
if (replace) {
this.rooms = Room.map((r) => this.fixRoomObject(r))
} else {
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) {
const lastRoom = Room[Room.length - 1]
this.roomCursor = lastRoom.lastMessageAt || lastRoom.createdAt
}
if (Room.length < ROOM_PAGE_SIZE) {
this.roomsLoaded = true
}
if (this.singleRoom && this.rooms.length > 0) {
this.activeRoomId = this.rooms[0].roomId
}
} catch (error) {
this.rooms = []
this.$toast.error(error.message)
}
this.loadingRooms = false
},
async searchRooms(event) {
const value = typeof event === 'string' ? event : event?.value
this.roomSearch = value || ''
this.roomCursor = null
if (this.roomsLoaded) this.roomObserverDirty = true
this.roomSearchGeneration = (this.roomSearchGeneration || 0) + 1
const generation = this.roomSearchGeneration
await this.fetchRooms({ search: this.roomSearch || undefined, replace: true })
if (generation !== this.roomSearchGeneration) return
// Re-init IntersectionObserver after it was disabled by roomsLoaded=true
if (this.roomObserverDirty && !this.roomsLoaded) {
this.roomObserverDirty = false
this.$nextTick(() => this.reinitRoomLoader())
}
},
reinitRoomLoader() {
const shadow = this.$el?.shadowRoot
if (!shadow) return
const loader = shadow.querySelector('#infinite-loader-rooms')
const roomsList = shadow.querySelector('#rooms-list')
if (!loader || !roomsList) return
// Make loader visible (library hides it via v-show when showLoader=false)
loader.style.display = ''
// Create a new IntersectionObserver to trigger pagination on scroll
if (this._roomLoaderObserver) this._roomLoaderObserver.disconnect()
this._roomLoaderObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.roomsLoaded) {
this.fetchMoreRooms()
}
},
{ root: roomsList, threshold: 0 },
)
this._roomLoaderObserver.observe(loader)
},
fetchMoreRooms() {
this.fetchRooms({ search: this.roomSearch || undefined })
},
async fetchMessages({ room, options = {} }) {
if (!room?.roomId) return
// When an external socket message added a new room, the library may try to
// auto-select it. Lock activeRoomId to the current room to prevent focus steal.
if (this._externalRoomIds?.has(room.roomId)) {
this._externalRoomIds.delete(room.roomId)
if (this.selectedRoom) {
this.activeRoomId = this.selectedRoom.roomId
}
}
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: MESSAGE_PAGE_SIZE,
}
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 < MESSAGE_PAGE_SIZE) {
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)
let freshlyFetched = false
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])
if (!this._externalRoomIds) this._externalRoomIds = new Set()
this._externalRoomIds.add(newRoom.roomId)
this.rooms = [newRoom, ...this.rooms]
roomIndex = 0
freshlyFetched = true
} else {
return
}
} catch {
return
}
}
const changedRoom = { ...this.rooms[roomIndex] }
changedRoom.lastMessage = this.buildLastMessage(msg)
changedRoom.lastMessageAt = msg.date
changedRoom.index = new Date().toISOString()
const isCurrentRoom = msg.room.id === this.selectedRoom?.id
const isOwnMessage = msg.senderId === this.currentUser.id
// Don't increment unreadCount for freshly fetched rooms — server count already includes this message
if (!freshlyFetched && !isCurrentRoom && !isOwnMessage) {
changedRoom.unreadCount++
}
this.moveRoomToTop(changedRoom, 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.$set(this.rooms, roomIndex, changedRoom)
}
}
},
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],
file.extension ? `${file.name}.${file.extension}` : file.name,
{ type: file.type },
),
name: file.name,
extension: file.extension || undefined,
type: file.type,
...(file.duration != null && { duration: file.duration }),
}))
: 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 || this.initialsAvatarUrl(this.currentUser.name),
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 = this.buildLastMessage({ content, files })
changedRoom.index = new Date().toISOString()
this.moveRoomToTop(changedRoom, 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)
}
},
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,
// 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,
userProfile: null,
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
avatar: room.avatar?.w320 || room.avatar,
lastMessage: room.lastMessage ? this.buildLastMessage(room.lastMessage) : { content: '' },
users: room.users.map((u) => {
return { ...u, username: u.name, avatar: u.avatar?.w320 }
}),
}
// Build user profiles from original room.users (before avatar was flattened to string)
const userProfiles = {}
for (const u of room.users) {
userProfiles[u.id] = { id: u.id, name: u.name, avatar: u.avatar }
}
fixedRoom._userProfiles = userProfiles
if (isGroupRoom) {
if (!fixedRoom.avatar) {
fixedRoom.avatar = room.group?.avatar?.w320 || room.group?.avatar || null
}
} else {
const otherUser = room.users.find((u) => u.id !== this.currentUser.id)
fixedRoom.userProfile = otherUser
? userProfiles[otherUser.id]
: { name: fixedRoom.roomName }
if (!fixedRoom.avatar) {
fixedRoom.avatar = otherUser?.avatar?.w320 || null
}
}
return fixedRoom
},
moveRoomToTop(room, roomId) {
const id = roomId || room.id
this.rooms = [room, ...this.rooms.filter((r) => r.id !== id)]
},
bringRoomToTopAndSelect(room) {
room.index = new Date().toISOString()
this.moveRoomToTop(room)
this.$nextTick(() => {
this.activeRoomId = room.roomId
})
},
findLocalRoom(predicate) {
const room = this.rooms.find(predicate)
if (room) this.bringRoomToTopAndSelect(room)
return room
},
async fetchServerRoom(variables) {
try {
const { data: { Room } } = await this.$apollo.query({
query: roomQuery(),
variables,
fetchPolicy: 'no-cache',
})
if (Room?.length) {
const room = this.fixRoomObject(Room[0])
this.bringRoomToTopAndSelect(room)
return room
}
} catch {
// Fall through
}
return null
},
async newRoom(userOrId) {
const userId = typeof userOrId === 'string' ? userOrId : userOrId.id
let userName = typeof userOrId === 'string' ? null : userOrId.name
let userAvatarObj = typeof userOrId === 'string' ? null : userOrId.avatar
let userAvatar = userAvatarObj?.w320 || userAvatarObj?.url || null
if (typeof userOrId === 'string') {
try {
const { data } = await this.$apollo.query({
query: userProfileQuery(),
variables: { id: userId },
fetchPolicy: 'no-cache',
})
const user = data.User?.[0]
if (user) {
userName = user.name
userAvatarObj = user.avatar
userAvatar = user.avatar?.w320 || user.avatar?.url || null
}
} catch {
// Fall through
}
if (!userName) userName = userId
}
if (this.findLocalRoom((r) => !r.isGroupRoom && r.users.some((u) => u.id === userId))) return
if (await this.fetchServerRoom({ userId })) return
// Create a virtual room (no backend call — room is created on first message)
const currentUserProfile = { id: this.currentUser.id, name: this.currentUser.name, avatar: this.currentUser.avatar }
const otherUserProfile = { id: userId, name: userName, avatar: userAvatarObj }
const virtualRoom = {
id: `temp-${userId}`,
roomId: `temp-${userId}`,
roomName: userName,
isGroupRoom: false,
groupProfile: null,
userProfile: otherUserProfile,
_userProfiles: {
[this.currentUser.id]: currentUserProfile,
[userId]: otherUserProfile,
},
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.roomsLoaded = true
this.loadingRooms = false
this.$nextTick(() => {
this.activeRoomId = virtualRoom.roomId
})
},
async newGroupRoom(groupId) {
if (this.findLocalRoom((r) => r.isGroupRoom && r.groupProfile?.id === groupId)) return
if (await this.fetchServerRoom({ groupId })) return
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)
},
navigateToUserProfile(userId) {
const profile = this.messageUserProfile(userId)
const slug = (profile.name || '').toLowerCase().replaceAll(' ', '-')
this.$router.push({ path: `/profile/${userId}/${slug}` })
},
redirectToUserProfile({ user }) {
const slug = (user.name || '').toLowerCase().replaceAll(' ', '-')
this.$router.push({ path: `/profile/${user.id}/${slug}` })
},
},
}
</script>
<style lang="scss" scoped>
.vac-avatar-profile {
margin-right: 15px;
}
.room-group-icon {
vertical-align: middle !important;
margin-right: 0;
}
.room-name-with-icon {
color: var(--chat-room-color-username);
font-weight: 500;
}
.vac-message-avatar {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
font-size: 11px;
align-self: flex-end;
margin: 0 0 2px;
}
.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>