|
|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
import axios from 'axios'
|
|
|
|
|
import { io, type Socket } from 'socket.io-client'
|
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
|
|
|
|
import { useAuth } from '../auth/auth-context'
|
|
|
|
|
import { renderMarkdown } from '../lib/markdown'
|
|
|
|
|
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
|
|
|
|
@@ -37,6 +37,11 @@ type XType = (typeof X_TYPES)[number]
|
|
|
|
|
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean }
|
|
|
|
|
type MemberItem = { userId: string; email: string; name: string; status: string }
|
|
|
|
|
|
|
|
|
|
type MenuItem =
|
|
|
|
|
| { kind: 'sep' }
|
|
|
|
|
| { kind: 'item'; label: string; onClick: () => void; danger?: boolean; disabled?: boolean }
|
|
|
|
|
type CtxMenu = { x: number; y: number; title: string; items: MenuItem[] }
|
|
|
|
|
|
|
|
|
|
export default function ChatPage() {
|
|
|
|
|
const { session, logout, ensureFreshToken, refreshGuilds, updateName } = useAuth()
|
|
|
|
|
const [selectedGuildId, setSelectedGuildId] = useState('')
|
|
|
|
|
@@ -64,6 +69,40 @@ export default function ChatPage() {
|
|
|
|
|
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
|
|
|
|
|
|
|
|
|
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
if (!items.length) return
|
|
|
|
|
setCtxMenu({ x: e.clientX, y: e.clientY, title, items })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copyText(text: string) {
|
|
|
|
|
navigator.clipboard?.writeText(text).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insertMention(id: string) {
|
|
|
|
|
setContent((c) => `${c.trim() ? c.replace(/\s*$/, '') + ' ' : ''}<@${id}> `)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!ctxMenu) return
|
|
|
|
|
const close = () => setCtxMenu(null)
|
|
|
|
|
const onKey = (ev: KeyboardEvent) => {
|
|
|
|
|
if (ev.key === 'Escape') close()
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('click', close)
|
|
|
|
|
window.addEventListener('resize', close)
|
|
|
|
|
window.addEventListener('scroll', close, true)
|
|
|
|
|
window.addEventListener('keydown', onKey)
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('click', close)
|
|
|
|
|
window.removeEventListener('resize', close)
|
|
|
|
|
window.removeEventListener('scroll', close, true)
|
|
|
|
|
window.removeEventListener('keydown', onKey)
|
|
|
|
|
}
|
|
|
|
|
}, [ctxMenu])
|
|
|
|
|
|
|
|
|
|
function toggleDevMode(on: boolean) {
|
|
|
|
|
setDevMode(on)
|
|
|
|
|
@@ -291,6 +330,31 @@ export default function ChatPage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function closeChannel(c: GuildChannel) {
|
|
|
|
|
if (!guild || !guildToken) return
|
|
|
|
|
try {
|
|
|
|
|
await guildApi().post(`/channels/${c.id}/close`)
|
|
|
|
|
await loadChannels()
|
|
|
|
|
} catch {
|
|
|
|
|
setError('Failed to close channel')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteMessage(m: MessageItem) {
|
|
|
|
|
if (!guild || !guildToken || !selectedChannelId) return
|
|
|
|
|
try {
|
|
|
|
|
await guildApi().delete(`/channels/${selectedChannelId}/messages/${m.messageId}`)
|
|
|
|
|
// socket message.deleted will reconcile; optimistic fallback:
|
|
|
|
|
setMessages((prev) =>
|
|
|
|
|
prev.map((x) =>
|
|
|
|
|
x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
} catch {
|
|
|
|
|
setError('Failed to delete message')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function leaveChannel(c: GuildChannel) {
|
|
|
|
|
if (!guild || !guildToken) return
|
|
|
|
|
try {
|
|
|
|
|
@@ -382,7 +446,11 @@ export default function ChatPage() {
|
|
|
|
|
const isBypass = bypassMemberIds.includes(m.userId)
|
|
|
|
|
const showBypassUi = inChannel && rotationChannel
|
|
|
|
|
return (
|
|
|
|
|
<div key={m.userId} className="member-row">
|
|
|
|
|
<div
|
|
|
|
|
key={m.userId}
|
|
|
|
|
className="member-row"
|
|
|
|
|
onContextMenu={(e) => openCtx(e, label, userMenu(m, inChannel))}
|
|
|
|
|
>
|
|
|
|
|
<div className="avatar dot">{initials(label)}</div>
|
|
|
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
|
|
|
<span className="nm">{label}</span>
|
|
|
|
|
@@ -402,15 +470,182 @@ export default function ChatPage() {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- right-click context menus (per resource) ----
|
|
|
|
|
function guildMenu(g: { nodeId: string; name: string }): MenuItem[] {
|
|
|
|
|
return [
|
|
|
|
|
{ kind: 'item', label: 'Open', onClick: () => setSelectedGuildId(g.nodeId) },
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{ kind: 'item', label: 'Copy node ID', onClick: () => copyText(g.nodeId) },
|
|
|
|
|
{ kind: 'item', label: 'Copy name', onClick: () => copyText(g.name) },
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{ kind: 'item', label: 'Add guild…', onClick: () => setShowAddGuildModal(true) },
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function channelMenu(c: GuildChannel): MenuItem[] {
|
|
|
|
|
const items: MenuItem[] = [
|
|
|
|
|
{ kind: 'item', label: 'Open', onClick: () => setSelectedChannelId(c.id) },
|
|
|
|
|
]
|
|
|
|
|
if (c.isMember)
|
|
|
|
|
items.push({ kind: 'item', label: 'Leave channel', danger: true, onClick: () => leaveChannel(c) })
|
|
|
|
|
else if (c.isPublic)
|
|
|
|
|
items.push({ kind: 'item', label: 'Join channel', onClick: () => joinChannel(c) })
|
|
|
|
|
if (!c.closed)
|
|
|
|
|
items.push({ kind: 'item', label: 'Close channel', danger: true, onClick: () => closeChannel(c) })
|
|
|
|
|
items.push(
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{ kind: 'item', label: 'Copy channel ID', onClick: () => copyText(c.id) },
|
|
|
|
|
{ kind: 'item', label: 'Copy name', onClick: () => copyText(c.name) },
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{ kind: 'item', label: 'Create channel…', onClick: openCreateChannel },
|
|
|
|
|
)
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function messageMenu(m: MessageItem): MenuItem[] {
|
|
|
|
|
const isGuild = m.authorUserId === 'guild'
|
|
|
|
|
const items: MenuItem[] = [
|
|
|
|
|
{ kind: 'item', label: 'Copy text', onClick: () => copyText(m.content) },
|
|
|
|
|
{ kind: 'item', label: 'Copy message ID', onClick: () => copyText(m.messageId) },
|
|
|
|
|
]
|
|
|
|
|
if (m.authorUserId && !isGuild)
|
|
|
|
|
items.push({
|
|
|
|
|
kind: 'item',
|
|
|
|
|
label: 'Mention author',
|
|
|
|
|
onClick: () => insertMention(m.authorUserId as string),
|
|
|
|
|
})
|
|
|
|
|
items.push(
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{
|
|
|
|
|
kind: 'item',
|
|
|
|
|
label: 'Delete message',
|
|
|
|
|
danger: true,
|
|
|
|
|
disabled: !!m.isDeleted || isGuild,
|
|
|
|
|
onClick: () => deleteMessage(m),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function userMenu(
|
|
|
|
|
m: { userId: string; name?: string; email?: string },
|
|
|
|
|
inChannel: boolean,
|
|
|
|
|
): MenuItem[] {
|
|
|
|
|
const isSelf = m.userId === session?.user.id
|
|
|
|
|
const label = m.name || m.email || m.userId.slice(0, 8)
|
|
|
|
|
const items: MenuItem[] = [
|
|
|
|
|
{ kind: 'item', label: 'Mention', onClick: () => insertMention(m.userId) },
|
|
|
|
|
{ kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) },
|
|
|
|
|
{ kind: 'item', label: 'Copy name', onClick: () => copyText(label) },
|
|
|
|
|
]
|
|
|
|
|
if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId))
|
|
|
|
|
items.push(
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{ kind: 'item', label: 'Move to bypass', onClick: () => moveToBypass(m.userId) },
|
|
|
|
|
)
|
|
|
|
|
if (isSelf)
|
|
|
|
|
items.push(
|
|
|
|
|
{ kind: 'sep' },
|
|
|
|
|
{
|
|
|
|
|
kind: 'item',
|
|
|
|
|
label: 'Settings…',
|
|
|
|
|
onClick: () => {
|
|
|
|
|
setSettingsName(session?.user.name ?? '')
|
|
|
|
|
setShowSettingsModal(true)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blankMenu(scope: 'guilds' | 'channels' | 'messages' | 'members' | 'root'): MenuItem[] {
|
|
|
|
|
if (scope === 'guilds')
|
|
|
|
|
return [{ kind: 'item', label: 'Add guild…', onClick: () => setShowAddGuildModal(true) }]
|
|
|
|
|
if (scope === 'channels')
|
|
|
|
|
return [
|
|
|
|
|
{ kind: 'item', label: 'Create channel…', disabled: !guild, onClick: openCreateChannel },
|
|
|
|
|
{ kind: 'item', label: 'Refresh channels', onClick: () => void loadChannels() },
|
|
|
|
|
]
|
|
|
|
|
if (scope === 'messages')
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
kind: 'item',
|
|
|
|
|
label: 'Refresh messages',
|
|
|
|
|
disabled: !selectedChannelId,
|
|
|
|
|
onClick: () => void pullMessages(),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
if (scope === 'members')
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
kind: 'item',
|
|
|
|
|
label: 'Refresh members',
|
|
|
|
|
onClick: () => {
|
|
|
|
|
void loadMembers()
|
|
|
|
|
void loadChannelMembers()
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
kind: 'item',
|
|
|
|
|
label: 'Refresh',
|
|
|
|
|
onClick: () => {
|
|
|
|
|
void loadChannels()
|
|
|
|
|
void loadMembers()
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderCtxMenu() {
|
|
|
|
|
if (!ctxMenu) return null
|
|
|
|
|
const MENU_W = 220
|
|
|
|
|
const estH =
|
|
|
|
|
36 +
|
|
|
|
|
ctxMenu.items.reduce((a, it) => a + (it.kind === 'sep' ? 9 : 32), 0)
|
|
|
|
|
const x = Math.max(6, Math.min(ctxMenu.x, window.innerWidth - MENU_W - 8))
|
|
|
|
|
const y = Math.max(6, Math.min(ctxMenu.y, window.innerHeight - estH - 8))
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="ctx-menu"
|
|
|
|
|
style={{ left: x, top: y, width: MENU_W }}
|
|
|
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<div className="ctx-title">{ctxMenu.title}</div>
|
|
|
|
|
{ctxMenu.items.map((it, i) =>
|
|
|
|
|
it.kind === 'sep' ? (
|
|
|
|
|
<div key={i} className="ctx-sep" />
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
key={i}
|
|
|
|
|
className={`ctx-item ${it.danger ? 'danger' : ''}`}
|
|
|
|
|
disabled={it.disabled}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (it.disabled) return
|
|
|
|
|
setCtxMenu(null)
|
|
|
|
|
it.onClick()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{it.label}
|
|
|
|
|
</button>
|
|
|
|
|
),
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="dc-shell">
|
|
|
|
|
<nav className="dc-rail">
|
|
|
|
|
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
|
|
|
|
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
|
|
|
|
|
{guilds.map((g) => (
|
|
|
|
|
<button
|
|
|
|
|
key={g.nodeId}
|
|
|
|
|
className={`rail-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`}
|
|
|
|
|
title={g.name}
|
|
|
|
|
onClick={() => setSelectedGuildId(g.nodeId)}
|
|
|
|
|
onContextMenu={(e) => openCtx(e, g.name, guildMenu(g))}
|
|
|
|
|
>
|
|
|
|
|
{initials(g.name)}
|
|
|
|
|
</button>
|
|
|
|
|
@@ -423,7 +658,10 @@ export default function ChatPage() {
|
|
|
|
|
|
|
|
|
|
<aside className="dc-sidebar">
|
|
|
|
|
<div className="dc-sidebar-head">{guild?.name ?? 'No guild selected'}</div>
|
|
|
|
|
<div className="dc-sidebar-scroll">
|
|
|
|
|
<div
|
|
|
|
|
className="dc-sidebar-scroll"
|
|
|
|
|
onContextMenu={(e) => openCtx(e, 'Channels', blankMenu('channels'))}
|
|
|
|
|
>
|
|
|
|
|
<div className="dc-section-label">
|
|
|
|
|
<span>Channels</span>
|
|
|
|
|
<button title="Create channel" onClick={openCreateChannel}>
|
|
|
|
|
@@ -436,6 +674,7 @@ export default function ChatPage() {
|
|
|
|
|
key={c.id}
|
|
|
|
|
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setSelectedChannelId(c.id)}
|
|
|
|
|
onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
|
|
|
|
|
>
|
|
|
|
|
<span className="hash">#</span>
|
|
|
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
|
|
|
|
@@ -499,7 +738,10 @@ export default function ChatPage() {
|
|
|
|
|
</button>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="dc-messages">
|
|
|
|
|
<div
|
|
|
|
|
className="dc-messages"
|
|
|
|
|
onContextMenu={(e) => openCtx(e, 'Messages', blankMenu('messages'))}
|
|
|
|
|
>
|
|
|
|
|
{loading ? <p className="muted" style={{ padding: 8 }}>Loading…</p> : null}
|
|
|
|
|
{!loading && !messages.length ? (
|
|
|
|
|
<div className="dc-empty-center">
|
|
|
|
|
@@ -509,7 +751,11 @@ export default function ChatPage() {
|
|
|
|
|
{messages
|
|
|
|
|
.filter((m) => devMode || m.authorUserId !== 'guild')
|
|
|
|
|
.map((m) => (
|
|
|
|
|
<div key={m.messageId} className={`msg ${m.isDeleted ? 'deleted' : ''}`}>
|
|
|
|
|
<div
|
|
|
|
|
key={m.messageId}
|
|
|
|
|
className={`msg ${m.isDeleted ? 'deleted' : ''}`}
|
|
|
|
|
onContextMenu={(e) => openCtx(e, authorLabel(m.authorUserId), messageMenu(m))}
|
|
|
|
|
>
|
|
|
|
|
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
|
|
|
|
|
<div className="body">
|
|
|
|
|
<div className="meta">
|
|
|
|
|
@@ -569,7 +815,10 @@ export default function ChatPage() {
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
{showMembers ? (
|
|
|
|
|
<aside className="dc-members">
|
|
|
|
|
<aside
|
|
|
|
|
className="dc-members"
|
|
|
|
|
onContextMenu={(e) => openCtx(e, 'Members', blankMenu('members'))}
|
|
|
|
|
>
|
|
|
|
|
{currentChannel && !currentChannel.isPublic ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="dc-section-label">
|
|
|
|
|
@@ -784,6 +1033,8 @@ export default function ChatPage() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{renderCtxMenu()}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|