feat(frontend): redesign post-login layout into guild/channel/chat panes
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
|
||||
type MessageItem = {
|
||||
@@ -11,35 +10,28 @@ type MessageItem = {
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
type PageInfo = {
|
||||
nextExpectedSeq?: number
|
||||
highestCommittedSeq?: number
|
||||
hasMore?: boolean
|
||||
}
|
||||
type GuildChannel = { id: string; name: string }
|
||||
|
||||
export default function ChatPage() {
|
||||
const { session } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const guildId = searchParams.get('guildId') ?? ''
|
||||
const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '')
|
||||
const [content, setContent] = useState('')
|
||||
const { session, logout } = useAuth()
|
||||
const [selectedGuildId, setSelectedGuildId] = useState('')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState('')
|
||||
const [channels, setChannels] = useState<GuildChannel[]>([])
|
||||
const [messages, setMessages] = useState<MessageItem[]>([])
|
||||
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null)
|
||||
const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
|
||||
const [onlineCount, setOnlineCount] = useState(0)
|
||||
const [typingUsers, setTypingUsers] = useState<string[]>([])
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [seqFrom, setSeqFrom] = useState('1')
|
||||
const [seqTo, setSeqTo] = useState('999999')
|
||||
const [limit, setLimit] = useState('50')
|
||||
const [editingMessageId, setEditingMessageId] = useState('')
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
|
||||
const guild = useMemo(() => (session?.guilds ?? []).find((g) => g.nodeId === guildId) ?? null, [session, guildId])
|
||||
const guilds = session?.guilds ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGuildId && guilds.length) setSelectedGuildId(guilds[0].nodeId)
|
||||
}, [guilds, selectedGuildId])
|
||||
|
||||
const guild = useMemo(() => guilds.find((g) => g.nodeId === selectedGuildId) ?? null, [guilds, selectedGuildId])
|
||||
const guildToken = useMemo(
|
||||
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '',
|
||||
[session, guildId],
|
||||
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === selectedGuildId)?.token ?? '',
|
||||
[session, selectedGuildId],
|
||||
)
|
||||
|
||||
function guildApi() {
|
||||
@@ -47,9 +39,7 @@ export default function ChatPage() {
|
||||
return axios.create({
|
||||
baseURL: `${guild.endpoint}/api`,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${guildToken}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${guildToken}` },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,75 +47,34 @@ export default function ChatPage() {
|
||||
if (!guild || !guildToken) return null
|
||||
return io(`${guild.endpoint}/realtime`, {
|
||||
transports: ['websocket'],
|
||||
auth: {
|
||||
token: guildToken,
|
||||
},
|
||||
auth: { token: guildToken },
|
||||
autoConnect: false,
|
||||
})
|
||||
}, [guild, guildToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return
|
||||
|
||||
socket.on('connect', () => {
|
||||
setSocketState('online')
|
||||
if (!channelId) return
|
||||
socket.emit('join_channel', { channelId })
|
||||
void guildApi()
|
||||
.get(`/channels/${channelId}/messages`, {
|
||||
params: {
|
||||
seq_from: Number(seqFrom || '1'),
|
||||
seq_to: Number(seqTo || '999999'),
|
||||
limit: Number(limit || '50'),
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
setMessages(res.data.items ?? [])
|
||||
setPageInfo(res.data.page ?? null)
|
||||
})
|
||||
})
|
||||
socket.on('disconnect', () => setSocketState('offline'))
|
||||
socket.on('presence.online', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
|
||||
socket.on('presence.offline', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
|
||||
socket.on('typing.start', (e: { channelId?: string; userId?: string }) => {
|
||||
if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === session?.user.id) return
|
||||
setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string]))
|
||||
})
|
||||
socket.on('typing.stop', (e: { channelId?: string; userId?: string }) => {
|
||||
if (!e.channelId || e.channelId !== channelId || !e.userId) return
|
||||
setTypingUsers((prev) => prev.filter((x) => x !== e.userId))
|
||||
})
|
||||
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
|
||||
socket.on('message.updated', (m: MessageItem) =>
|
||||
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
|
||||
)
|
||||
socket.on('message.deleted', (m: MessageItem) =>
|
||||
setMessages((prev) =>
|
||||
prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)),
|
||||
),
|
||||
)
|
||||
|
||||
socket.connect()
|
||||
return () => {
|
||||
socket.removeAllListeners()
|
||||
socket.disconnect()
|
||||
async function loadChannels() {
|
||||
if (!guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
const res = await guildApi().get('/channels')
|
||||
const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
|
||||
setChannels(list)
|
||||
if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
|
||||
} catch {
|
||||
setError('Failed to load channels')
|
||||
setChannels([])
|
||||
}
|
||||
}, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
|
||||
}
|
||||
|
||||
async function pullMessages() {
|
||||
if (!channelId || !guild || !guildToken) return
|
||||
if (!selectedChannelId || !guild || !guildToken) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await guildApi().get(`/channels/${channelId}/messages`, {
|
||||
params: {
|
||||
seq_from: Number(seqFrom || '1'),
|
||||
seq_to: Number(seqTo || '999999'),
|
||||
limit: Number(limit || '50'),
|
||||
},
|
||||
const res = await guildApi().get(`/channels/${selectedChannelId}/messages`, {
|
||||
params: { seq_from: 1, seq_to: 999999, limit: 100 },
|
||||
})
|
||||
setMessages(res.data.items ?? [])
|
||||
setPageInfo(res.data.page ?? null)
|
||||
} catch {
|
||||
setError('Failed to load messages')
|
||||
} finally {
|
||||
@@ -134,117 +83,106 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!channelId || !content.trim() || !guild || !guildToken) return
|
||||
if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await guildApi().post(`/channels/${channelId}/messages`, {
|
||||
await guildApi().post(`/channels/${selectedChannelId}/messages`, {
|
||||
content,
|
||||
authorUserId: session?.user.id ?? 'unknown',
|
||||
})
|
||||
socket?.emit('typing.stop', { channelId })
|
||||
setContent('')
|
||||
await pullMessages()
|
||||
} catch {
|
||||
setError('Failed to send message')
|
||||
}
|
||||
}
|
||||
|
||||
async function editMessage() {
|
||||
if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, {
|
||||
content: editingContent,
|
||||
})
|
||||
setEditingContent('')
|
||||
await pullMessages()
|
||||
} catch {
|
||||
setError('Failed to edit message')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessage(messageId: string) {
|
||||
if (!channelId || !messageId || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
|
||||
await pullMessages()
|
||||
} catch {
|
||||
setError('Failed to delete message')
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeChannel(value: string) {
|
||||
setChannelId(value)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (guildId) next.set('guildId', guildId)
|
||||
if (value) next.set('channelId', value)
|
||||
else next.delete('channelId')
|
||||
setSearchParams(next)
|
||||
setTypingUsers([])
|
||||
}
|
||||
|
||||
function onTypingChange(value: string) {
|
||||
const wasEmpty = !content.trim()
|
||||
const nowEmpty = !value.trim()
|
||||
setContent(value)
|
||||
if (!channelId || !socket) return
|
||||
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId })
|
||||
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId })
|
||||
}
|
||||
useEffect(() => {
|
||||
void loadChannels()
|
||||
setMessages([])
|
||||
setSelectedChannelId('')
|
||||
}, [selectedGuildId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId || !socket || !socket.connected) return
|
||||
socket.emit('join_channel', { channelId })
|
||||
void pullMessages()
|
||||
}, [selectedChannelId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !selectedChannelId) return
|
||||
|
||||
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
|
||||
socket.on('message.updated', (m: MessageItem) =>
|
||||
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
|
||||
)
|
||||
socket.on('message.deleted', (m: MessageItem) =>
|
||||
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x))),
|
||||
)
|
||||
|
||||
socket.connect()
|
||||
socket.emit('join_channel', { channelId: selectedChannelId })
|
||||
|
||||
return () => {
|
||||
socket.emit('leave_channel', { channelId })
|
||||
socket.emit('leave_channel', { channelId: selectedChannelId })
|
||||
socket.removeAllListeners()
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [channelId, socket, socketState])
|
||||
}, [socket, selectedChannelId])
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<h2>Chat</h2>
|
||||
<p className="muted">Guild: {guildId || '-'}</p>
|
||||
<p className="muted">Socket: {socketState}</p>
|
||||
<p className="muted">Online users: {onlineCount}</p>
|
||||
{loading ? <p className="muted">Loading...</p> : null}
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
{typingUsers.length ? <p className="muted">Typing: {typingUsers.join(', ')}</p> : null}
|
||||
<div className="row-wrap" style={{ marginBottom: 10 }}>
|
||||
<input className="input" value={channelId} onChange={(e) => onChangeChannel(e.target.value)} placeholder="Channel ID" />
|
||||
<input className="input" value={seqFrom} onChange={(e) => setSeqFrom(e.target.value)} placeholder="seq_from" />
|
||||
<input className="input" value={seqTo} onChange={(e) => setSeqTo(e.target.value)} placeholder="seq_to" />
|
||||
<input className="input" value={limit} onChange={(e) => setLimit(e.target.value)} placeholder="limit" />
|
||||
<button className="btn btn-secondary" onClick={pullMessages}>Fetch</button>
|
||||
<section className="chat-layout">
|
||||
<div className="chat-top-grid">
|
||||
<div className="panel col-list">
|
||||
<h3>Guilds</h3>
|
||||
<ul className="list-reset">
|
||||
{guilds.map((g) => (
|
||||
<li key={g.nodeId}>
|
||||
<button className={`list-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`} onClick={() => setSelectedGuildId(g.nodeId)}>
|
||||
{g.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="panel col-list">
|
||||
<h3>Channels</h3>
|
||||
<ul className="list-reset">
|
||||
{channels.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button className={`list-btn ${selectedChannelId === c.id ? 'active' : ''}`} onClick={() => setSelectedChannelId(c.id)}>
|
||||
#{c.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="chat-right">
|
||||
<div className="panel chat-history">
|
||||
<h3>Messages</h3>
|
||||
{loading ? <p className="muted">Loading...</p> : null}
|
||||
{error ? <p className="error-text">{error}</p> : null}
|
||||
<ul className="list-reset">
|
||||
{messages.map((m) => (
|
||||
<li key={m.messageId} className="card" style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<strong>#{m.seq}</strong> {m.content}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="panel composer">
|
||||
<input className="input" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Type a message" />
|
||||
<button className="btn" onClick={sendMessage}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-wrap" style={{ marginBottom: 10 }}>
|
||||
<input className="input" value={content} onChange={(e) => onTypingChange(e.target.value)} placeholder="Type a message" />
|
||||
<button className="btn" onClick={sendMessage}>Send</button>
|
||||
|
||||
<div className="panel footer-actions">
|
||||
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
|
||||
<button className="btn btn-secondary" type="button">Settings</button>
|
||||
</div>
|
||||
<div className="row-wrap" style={{ marginBottom: 10 }}>
|
||||
<input className="input" value={editingMessageId} onChange={(e) => setEditingMessageId(e.target.value)} placeholder="messageId" />
|
||||
<input className="input" value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="Updated content" />
|
||||
<button className="btn btn-secondary" onClick={editMessage}>Edit</button>
|
||||
</div>
|
||||
<ul className="list-reset">
|
||||
{messages.map((m) => (
|
||||
<li key={m.messageId} className="card" style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<strong>#{m.seq}</strong> {m.content}
|
||||
</div>
|
||||
<div className="muted">{m.messageId}</div>
|
||||
<button className="btn btn-danger" type="button" onClick={() => deleteMessage(m.messageId)}>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!loading && !messages.length ? <p className="muted">No messages yet</p> : null}
|
||||
{pageInfo ? (
|
||||
<p>
|
||||
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
|
||||
has_more: {String(pageInfo.hasMore ?? false)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user