big update: added reactions, groups, media share
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 21:59:48 +03:00
parent ae24bdd0e0
commit 9d03ea4c51
41 changed files with 2548 additions and 172 deletions
+99 -9
View File
@@ -7,7 +7,7 @@ import { useAuth } from '../auth/AuthContext'
import { useConversations } from '../hooks/useConversations'
import { useMessages } from '../hooks/useMessages'
import { useRealtime } from '../hooks/useRealtime'
import type { ClientMessage, Message, User, WsEvent } from '../lib/types'
import type { ClientMessage, Media, Member, Message, User, WsEvent } from '../lib/types'
interface ChatAppProps {
user: User
@@ -22,8 +22,9 @@ export default function ChatApp({ user }: ChatAppProps) {
const [typing, setTyping] = useState<Record<number, boolean>>({})
const [dialogOpen, setDialogOpen] = useState(false)
const [focusTick, setFocusTick] = useState(0)
const [membersVersion, setMembersVersion] = useState(0)
const { conversations, loading, error, reload, add, patch } = useConversations()
const { conversations, loading, error, reload, add, remove, patch } = useConversations()
const {
messages,
loading: messagesLoading,
@@ -32,6 +33,7 @@ export default function ChatApp({ user }: ChatAppProps) {
error: messagesError,
loadOlder,
upsert,
patchMessage,
addOptimistic,
markSending,
failOptimistic,
@@ -96,8 +98,16 @@ export default function ChatApp({ user }: ChatAppProps) {
(event: WsEvent) => {
switch (event.type) {
case 'message.created': {
const { temp_id, conversation_id, message_id, sender_id, body, created_at } = event.data
const message: Message = { id: message_id, conversation_id, sender_id, body, created_at }
const { temp_id, conversation_id, message_id, sender_id, body, created_at, reactions, media } = event.data
const message: Message = {
id: message_id,
conversation_id,
sender_id,
body,
created_at,
reactions: reactions ?? [],
media: media ?? null,
}
if (conversation_id === selectedIdRef.current || sender_id === user.id) {
upsert(message, temp_id)
}
@@ -146,23 +156,64 @@ export default function ChatApp({ user }: ChatAppProps) {
if (conversation_id === selectedIdRef.current) markReadUpTo(message_id)
break
}
case 'reaction.added': {
const { conversation_id, message_id, user_id, emoji } = event.data
if (conversation_id !== selectedIdRef.current) break
patchMessage(message_id, (message) => {
if (message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user_id)) {
return message
}
return { ...message, reactions: [...message.reactions, { emoji, user_id }] }
})
break
}
case 'reaction.removed': {
const { conversation_id, message_id, user_id, emoji } = event.data
if (conversation_id !== selectedIdRef.current) break
patchMessage(message_id, (message) => ({
...message,
reactions: message.reactions.filter(
(reaction) => !(reaction.emoji === emoji && reaction.user_id === user_id),
),
}))
break
}
case 'member.added':
case 'member.removed': {
if (event.data.conversation_id === selectedIdRef.current) {
setMembersVersion((version) => version + 1)
}
break
}
case 'conversation.added': {
void reload()
break
}
case 'conversation.removed': {
const { conversation_id } = event.data
remove(conversation_id)
if (selectedIdRef.current === conversation_id) setSelectedId(null)
break
}
}
},
[markReadUpTo, patch, reload, upsert, user.id],
[markReadUpTo, patch, patchMessage, reload, remove, upsert, user.id],
)
const realtime = useRealtime(handleEvent)
const sendMessage = useCallback(
async (conversationId: number, body: string) => {
async (conversationId: number, body: string, media: Media | null) => {
const text = body.trim()
if (!text) return
if (!text && !media) return
const tempId = crypto.randomUUID()
const optimistic: ClientMessage = {
id: -Date.now(),
conversation_id: conversationId,
sender_id: user.id,
body: text,
reactions: [],
media,
created_at: new Date().toISOString(),
temp_id: tempId,
status: 'sending',
@@ -170,7 +221,7 @@ export default function ChatApp({ user }: ChatAppProps) {
addOptimistic(optimistic)
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
try {
const result = await api.sendMessage(conversationId, text, tempId)
const result = await api.sendMessage(conversationId, text, tempId, media?.id)
upsert(result.message, tempId)
patch(conversationId, (conversation) => ({
...conversation,
@@ -190,7 +241,7 @@ export default function ChatApp({ user }: ChatAppProps) {
if (!tempId) return
markSending(tempId)
try {
const result = await api.sendMessage(message.conversation_id, message.body, tempId)
const result = await api.sendMessage(message.conversation_id, message.body, tempId, message.media?.id)
upsert(result.message, tempId)
patch(message.conversation_id, (conversation) => ({
...conversation,
@@ -204,6 +255,22 @@ export default function ChatApp({ user }: ChatAppProps) {
[failOptimistic, markSending, patch, upsert],
)
const handleReaction = useCallback(
async (message: ClientMessage, emoji: string) => {
if (message.id <= 0) return
const mine = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user.id)
try {
const updated = mine
? await api.removeReaction(message.conversation_id, message.id, emoji)
: await api.addReaction(message.conversation_id, message.id, emoji)
upsert(updated)
} catch {
return
}
},
[upsert, user.id],
)
const sendTyping = useCallback(
(conversationId: number, isTyping: boolean) => {
realtime.sendTyping(conversationId, isTyping)
@@ -233,6 +300,23 @@ export default function ChatApp({ user }: ChatAppProps) {
[add, loadContacts, rememberUser],
)
const handleCreateGroup = useCallback(
async (name: string, memberIds: number[]) => {
const conversation = await api.createGroup(name, memberIds)
add(conversation)
setSelectedId(conversation.id)
setMembersVersion((version) => version + 1)
},
[add],
)
const handleMembersLoaded = useCallback(
(members: Member[]) => {
members.forEach((member) => rememberUser(member.user))
},
[rememberUser],
)
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null
const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null
@@ -264,6 +348,7 @@ export default function ChatApp({ user }: ChatAppProps) {
/>
<ChatWindow
key={selectedId ?? 'empty'}
className={selectedId === null ? 'hidden md:flex' : 'flex'}
conversation={conversations.find((conversation) => conversation.id === selectedId) ?? null}
currentUser={user}
@@ -276,11 +361,15 @@ export default function ChatApp({ user }: ChatAppProps) {
loadingOlder={loadingOlder}
hasMore={hasMore}
error={messagesError}
membersVersion={membersVersion}
onBack={() => setSelectedId(null)}
onSend={sendMessage}
onTyping={sendTyping}
onLoadOlder={() => void loadOlder()}
onRetry={resendMessage}
onReact={(message, emoji) => void handleReaction(message, emoji)}
onMembersChanged={() => setMembersVersion((version) => version + 1)}
onMembersLoaded={handleMembersLoaded}
/>
</div>
@@ -288,6 +377,7 @@ export default function ChatApp({ user }: ChatAppProps) {
<NewChatDialog
onClose={() => setDialogOpen(false)}
onStartChat={handleStartChat}
onCreateGroup={handleCreateGroup}
onContactsChanged={() => void loadContacts()}
/>
)}