Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 97 additions & 15 deletions components/chat/ChatConversation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { toast } from 'vue-sonner'

// Props
const props = defineProps<{
conversationId: string
conversationId?: string | null
recipientId?: string | null
}>()

//State Management
Expand All @@ -31,23 +32,64 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const { $client } = useNuxtApp()
const { session } = useAppAuth()

const route = useRoute()
const router = useRouter()

// Computed Properties
const currentUser = computed(() => session.value?.profile)
const charCount = computed(() => graphemeCount(newMessage.value.trim()))
// Fetch recipient profile for new conversations
const { data: recipientProfile } = useAsyncData(
`profile-${props.recipientId}`,
() => {
if (!props.recipientId) return Promise.resolve(null)
return $client.profiles.getById.query(props.recipientId)
},
{
watch: [() => props.recipientId],
}
)
const otherParticipant = computed(() => {
if (!conversation.value || !currentUser.value) return null
if (conversation.value && currentUser.value) {
const conv = conversation.value
const currentUserId = currentUser.value?.id
return conv.aId === currentUserId ? conv.b : conv.a
}

const conv = conversation.value
const currentUserId = currentUser.value?.id
// For new conversations, use recipient profile
if (recipientProfile.value) {
return {
id: recipientProfile.value.id,
name: recipientProfile.value.name,
photo: recipientProfile.value.photo,
}
}

// Return the participant that's not the current user
return conv.aId === currentUserId ? conv.b : conv.a
// For new conversations, show minimal info
if (props.recipientId) {
return {
id: props.recipientId,
name: 'User',
photo: null,
}
}

return null
})

const isNewConversation = computed(
() => !!props.recipientId && !props.conversationId
)

// Lifecycle Hooks
onMounted(() => {
subscribeToConversation(props.conversationId)
markAsRead(props.conversationId)
if (props.conversationId) {
subscribeToConversation(props.conversationId)
markAsRead(props.conversationId)
} else {
// New conversation - no subscription needed yet
isLoading.value = false
}
})

onUnmounted(() => {
Expand Down Expand Up @@ -137,7 +179,7 @@ function subscribeToConversation(id: string) {
if (reconnectTimer) clearTimeout(reconnectTimer)

reconnectTimer = setTimeout(() => {
if (eventSource && eventSource.readyState === 1) {
if (eventSource && eventSource.readyState === 1 && props.conversationId) {
eventSource.close()
subscribeToConversation(props.conversationId)
}
Expand All @@ -157,7 +199,9 @@ function subscribeToConversation(id: string) {
const delay = Math.min(reconnectDelay.value, 10000)
reconnectDelay.value = Math.min(delay * 1.5, 10000)
setTimeout(() => {
subscribeToConversation(props.conversationId)
if (props.conversationId) {
subscribeToConversation(props.conversationId)
}
}, delay)
} else {
error.value = new Error('Connection to chat server failed.')
Expand All @@ -173,16 +217,32 @@ async function sendMessage() {
toast.error('Message cannot be longer than 500 characters.')
return
}

try {
isSending.value = true
await $client.chat.sendMessage.mutate({
conversationId: props.conversationId,
content,
})

if (props.conversationId) {
// Existing conversation
await $client.chat.sendMessage.mutate({
conversationId: props.conversationId,
content,
})
} else if (props.recipientId) {
// New conversation - create it with first message
const result = await $client.chat.sendMessage.mutate({
recipientId: props.recipientId,
content,
})

// Replace URL with real conversation ID
await router.replace(`/chat/${result.conversationId}`)
}

newMessage.value = ''
// The SSE listener will handle refreshing the conversation data
} catch (err) {
console.error('Failed to send message:', err)
toast.error('Failed to send message. Please try again.')
} finally {
isSending.value = false
}
Expand Down Expand Up @@ -277,7 +337,20 @@ function formatTime(timestamp: string | Date) {
<Skeleton class="h-4 w-24" />
</div>
</div>
<div v-else-if="conversation" class="flex items-center flex-1">
<!-- New conversation loading state -->
<div
v-else-if="isNewConversation && !otherParticipant"
class="flex items-center gap-3 flex-1"
>
<Skeleton class="h-8 w-8 rounded-full" />
<Skeleton class="h-4 w-24" />
</div>

<!-- Loaded state for both existing and new conversations -->
<div
v-else-if="conversation || otherParticipant"
class="flex items-center flex-1"
>
<div class="flex-shrink-0">
<img
v-if="otherParticipant?.photo"
Expand Down Expand Up @@ -320,6 +393,15 @@ function formatTime(timestamp: string | Date) {
<div v-else-if="error" class="p-4 text-red-500">
{{ error?.message || 'Failed to load conversation' }}
</div>
<!-- Empty state for new conversations -->
<div v-else-if="isNewConversation" class="text-center text-gray-500 py-8">
<p>Start a new conversation</p>
<p class="text-sm mt-2">
Send a message to {{ otherParticipant?.name || 'this user' }} below.
</p>
</div>

<!-- Empty state for existing conversations -->
<div
v-else-if="!conversation?.messages?.length"
class="text-center text-gray-500 py-8"
Expand Down
9 changes: 1 addition & 8 deletions composables/useContact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,7 @@ export function useContact() {
//else start a conversation
try {
pending.value = true
const conversation = await $client.chat.createConversation.mutate({
otherUserId: profile.id,
})
if (conversation?.id) {
await navigateTo(`/chat/${conversation.id}`)
} else {
toast.error('Could not start the conversation. Please try again.')
}
await navigateTo(`/chat/new-${profile.id}`)
} catch (error) {
toast.error('Failed to start the conversation. Please try again.')
} finally {
Expand Down
24 changes: 22 additions & 2 deletions pages/chat/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
<div
class="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden h-[600px]"
>
<ChatConversation :conversation-id="conversationId" />
<ChatConversation
:conversation-id="conversationId"
:recipient-id="recipientId"
/>
</div>
</div>
</div>
Expand All @@ -14,5 +17,22 @@
import { useRoute } from 'vue-router'

const route = useRoute()
const conversationId = route.params.id as string

const conversationId = computed(() => {
const id = route.params.id as string
// If ID starts with "new-", it's a new conversation
if (id.startsWith('new-')) {
return null
}
return id
})

const recipientId = computed(() => {
const id = route.params.id as string
// Extract recipient ID from "new-{recipientId}" format
if (id.startsWith('new-')) {
return id.substring(4) // Remove "new-" prefix
}
return null
})
</script>
9 changes: 3 additions & 6 deletions pages/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,11 @@ async function createConversation() {
try {
isCreating.value = true

const conversation = await $client.chat.createConversation.mutate({
otherUserId: selectedUser.value.id,
})

// Navigate to new conversation URL instead of creating empty conversation
showNewConversationModal.value = false
router.push(`/chat/${conversation.id}`)
await router.push(`/chat/new-${selectedUser.value.id}`)
} catch (error) {
console.error('Error creating conversation:', error)
console.error('Error navigating to conversation:', error)
} finally {
isCreating.value = false
}
Expand Down
14 changes: 10 additions & 4 deletions schemas/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,16 @@ export const createConversationSchema = z.object({
export type CreateConversationInput = z.infer<typeof createConversationSchema>

// Input for sending a message
export const sendMessageSchema = z.object({
conversationId: z.string(),
content: contentSchema,
})
export const sendMessageSchema = z
.object({
conversationId: z.string().optional(),
recipientId: z.string().optional(),
content: contentSchema,
})
.refine(
(data) => !!data.conversationId !== !!data.recipientId,
'Either conversationId or recipientId must be provided, but not both.'
)

export type SendMessageInput = z.infer<typeof sendMessageSchema>

Expand Down
Loading
Loading