feat(frontend): login with center URL and consume center-issued guild tokens
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
*.log
|
||||
.env*
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
17
docker/nginx.conf
Normal file
17
docker/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
() => ({
|
||||
session,
|
||||
isAuthed: !!session,
|
||||
login: async (email: string, password: string) => {
|
||||
const next = await loginCenter({ email, password })
|
||||
login: async (centerApiBase: string, email: string, password: string) => {
|
||||
const next = await loginCenter(centerApiBase, { email, password })
|
||||
setAuthSession(next)
|
||||
setSession(next)
|
||||
},
|
||||
logout: async () => {
|
||||
if (session?.refreshToken) {
|
||||
try {
|
||||
await logoutCenter(session.refreshToken)
|
||||
await logoutCenter(session.centerApiBase, session.refreshToken)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
if (!session) return null
|
||||
if (!isAccessTokenStale(session.accessToken)) return session.accessToken
|
||||
|
||||
const refreshed = await refreshCenter(session.refreshToken)
|
||||
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
|
||||
const next: AuthSession = {
|
||||
...session,
|
||||
accessToken: refreshed.accessToken,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AuthSession } from '../lib/auth-storage'
|
||||
export type AuthContextValue = {
|
||||
session: AuthSession | null
|
||||
isAuthed: boolean
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
login: (centerApiBase: string, email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
ensureFreshToken: () => Promise<string | null>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type AuthSession = {
|
||||
centerApiBase: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
@@ -6,6 +7,17 @@ export type AuthSession = {
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
guilds: Array<{
|
||||
nodeId: string
|
||||
name: string
|
||||
endpoint: string
|
||||
status: 'active' | 'offline' | 'revoked'
|
||||
}>
|
||||
guildAccessTokens: Array<{
|
||||
guildNodeId: string
|
||||
token: string
|
||||
tokenType: string
|
||||
}>
|
||||
}
|
||||
|
||||
const KEY = 'fabric.auth.session.v1'
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import axios from 'axios'
|
||||
import { getRuntimeConfig } from './runtime-config'
|
||||
import type { AuthSession } from './auth-storage'
|
||||
|
||||
export type LoginPayload = { email: string; password: string }
|
||||
|
||||
type LoginResponse = {
|
||||
centerApiBase: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
user: { id: string; email: string }
|
||||
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
|
||||
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string }>
|
||||
}
|
||||
|
||||
type RefreshResponse = {
|
||||
@@ -17,17 +19,14 @@ type RefreshResponse = {
|
||||
tokenType: string
|
||||
}
|
||||
|
||||
function centerClient() {
|
||||
const cfg = getRuntimeConfig()
|
||||
function centerClient(centerApiBase: string) {
|
||||
const client = axios.create({
|
||||
baseURL: cfg.centerApiBase,
|
||||
baseURL: centerApiBase,
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
client.interceptors.request.use((request) => {
|
||||
const { apiKey } = getRuntimeConfig()
|
||||
const requestId = crypto.randomUUID()
|
||||
if (apiKey) request.headers['x-api-key'] = apiKey
|
||||
request.headers['x-request-id'] = requestId
|
||||
request.headers['x-client-name'] = 'fabric-frontend'
|
||||
return request
|
||||
@@ -36,16 +35,16 @@ function centerClient() {
|
||||
return client
|
||||
}
|
||||
|
||||
export async function loginCenter(payload: LoginPayload): Promise<AuthSession> {
|
||||
const res = await centerClient().post<LoginResponse>('/auth/login', payload)
|
||||
export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise<AuthSession> {
|
||||
const res = await centerClient(centerApiBase).post<LoginResponse>('/auth/login', payload)
|
||||
return { ...res.data, centerApiBase }
|
||||
}
|
||||
|
||||
export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise<RefreshResponse> {
|
||||
const res = await centerClient(centerApiBase).post<RefreshResponse>('/auth/refresh', { refreshToken })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function refreshCenter(refreshToken: string): Promise<RefreshResponse> {
|
||||
const res = await centerClient().post<RefreshResponse>('/auth/refresh', { refreshToken })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function logoutCenter(refreshToken: string): Promise<void> {
|
||||
await centerClient().post('/auth/logout', { refreshToken })
|
||||
export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
|
||||
await centerClient(centerApiBase).post('/auth/logout', { refreshToken })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { getApiClient } from '../lib/api-client'
|
||||
import { disconnectSocket, getSocketClient } from '../lib/socket-client'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
|
||||
type MessageItem = {
|
||||
messageId: string
|
||||
@@ -17,6 +18,7 @@ type PageInfo = {
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { session } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const guildId = searchParams.get('guildId') ?? ''
|
||||
const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '')
|
||||
@@ -34,14 +36,42 @@ export default function ChatPage() {
|
||||
const [editingMessageId, setEditingMessageId] = useState('')
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
|
||||
const socket = useMemo(() => getSocketClient('frontend-user'), [])
|
||||
const guild = useMemo(() => (session?.guilds ?? []).find((g) => g.nodeId === guildId) ?? null, [session, guildId])
|
||||
const guildToken = useMemo(
|
||||
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '',
|
||||
[session, guildId],
|
||||
)
|
||||
|
||||
function guildApi() {
|
||||
if (!guild || !guildToken) throw new Error('guild or token missing')
|
||||
return axios.create({
|
||||
baseURL: `${guild.endpoint}/api`,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${guildToken}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const socket = useMemo<Socket | null>(() => {
|
||||
if (!guild || !guildToken) return null
|
||||
return io(`${guild.endpoint}/realtime`, {
|
||||
transports: ['websocket'],
|
||||
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 getApiClient()
|
||||
void guildApi()
|
||||
.get(`/channels/${channelId}/messages`, {
|
||||
params: {
|
||||
seq_from: Number(seqFrom || '1'),
|
||||
@@ -55,14 +85,10 @@ export default function ChatPage() {
|
||||
})
|
||||
})
|
||||
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('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 === 'frontend-user') return
|
||||
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 }) => {
|
||||
@@ -78,20 +104,20 @@ export default function ChatPage() {
|
||||
prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)),
|
||||
),
|
||||
)
|
||||
socket.connect()
|
||||
|
||||
socket.connect()
|
||||
return () => {
|
||||
socket.removeAllListeners()
|
||||
disconnectSocket()
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [socket, channelId, seqFrom, seqTo, limit])
|
||||
}, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
|
||||
|
||||
async function pullMessages() {
|
||||
if (!channelId) return
|
||||
if (!channelId || !guild || !guildToken) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await getApiClient().get(`/channels/${channelId}/messages`, {
|
||||
const res = await guildApi().get(`/channels/${channelId}/messages`, {
|
||||
params: {
|
||||
seq_from: Number(seqFrom || '1'),
|
||||
seq_to: Number(seqTo || '999999'),
|
||||
@@ -108,14 +134,14 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!channelId || !content.trim()) return
|
||||
if (!channelId || !content.trim() || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await getApiClient().post(`/channels/${channelId}/messages`, {
|
||||
await guildApi().post(`/channels/${channelId}/messages`, {
|
||||
content,
|
||||
authorUserId: 'frontend-user',
|
||||
authorUserId: session?.user.id ?? 'unknown',
|
||||
})
|
||||
socket.emit('typing.stop', { channelId })
|
||||
socket?.emit('typing.stop', { channelId })
|
||||
setContent('')
|
||||
} catch {
|
||||
setError('发送失败')
|
||||
@@ -123,10 +149,10 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
async function editMessage() {
|
||||
if (!channelId || !editingMessageId || !editingContent.trim()) return
|
||||
if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await getApiClient().patch(`/channels/${channelId}/messages/${editingMessageId}`, {
|
||||
await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, {
|
||||
content: editingContent,
|
||||
})
|
||||
setEditingContent('')
|
||||
@@ -137,10 +163,10 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
async function deleteMessage(messageId: string) {
|
||||
if (!channelId || !messageId) return
|
||||
if (!channelId || !messageId || !guild || !guildToken) return
|
||||
setError('')
|
||||
try {
|
||||
await getApiClient().delete(`/channels/${channelId}/messages/${messageId}`)
|
||||
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
|
||||
await pullMessages()
|
||||
} catch {
|
||||
setError('删除失败')
|
||||
@@ -161,13 +187,13 @@ export default function ChatPage() {
|
||||
const wasEmpty = !content.trim()
|
||||
const nowEmpty = !value.trim()
|
||||
setContent(value)
|
||||
if (!channelId) return
|
||||
if (!channelId || !socket) return
|
||||
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId })
|
||||
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId || !socket.connected) return
|
||||
if (!channelId || !socket || !socket.connected) return
|
||||
socket.emit('join_channel', { channelId })
|
||||
return () => {
|
||||
socket.emit('leave_channel', { channelId })
|
||||
@@ -195,11 +221,7 @@ export default function ChatPage() {
|
||||
<button onClick={sendMessage}>发送</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
value={editingMessageId}
|
||||
onChange={(e) => setEditingMessageId(e.target.value)}
|
||||
placeholder="messageId"
|
||||
/>
|
||||
<input value={editingMessageId} onChange={(e) => setEditingMessageId(e.target.value)} placeholder="messageId" />
|
||||
<input value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="新内容" />
|
||||
<button onClick={editMessage}>编辑</button>
|
||||
</div>
|
||||
@@ -216,8 +238,8 @@ export default function ChatPage() {
|
||||
{!loading && !messages.length ? <p>暂无消息</p> : null}
|
||||
{pageInfo ? (
|
||||
<p>
|
||||
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq:{' '}
|
||||
{pageInfo.highestCommittedSeq ?? '-'} | has_more: {String(pageInfo.hasMore ?? false)}
|
||||
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
|
||||
has_more: {String(pageInfo.hasMore ?? false)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from '../auth/auth-context'
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthed, session } = useAuth()
|
||||
const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -14,7 +15,7 @@ export default function LoginPage() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await login(email, password)
|
||||
await login(centerApiBase.trim(), email, password)
|
||||
navigate('/workspace')
|
||||
} catch {
|
||||
setError('登录失败,请检查账号密码')
|
||||
@@ -26,6 +27,11 @@ export default function LoginPage() {
|
||||
<h2>登录</h2>
|
||||
{isAuthed ? <p>当前用户:{session?.user.email}</p> : null}
|
||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
|
||||
<input
|
||||
value={centerApiBase}
|
||||
onChange={(e) => setCenterApiBase(e.target.value)}
|
||||
placeholder="Center API Base (e.g. http://localhost:7001/api)"
|
||||
/>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
|
||||
<input
|
||||
value={password}
|
||||
|
||||
@@ -1,106 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getApiClient, resetApiClient } from '../lib/api-client'
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
setRuntimeConfig,
|
||||
} from '../lib/runtime-config'
|
||||
import type { RuntimeConfig } from '../lib/runtime-config'
|
||||
import { reconnectSocket } from '../lib/socket-client'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const [form, setForm] = useState<RuntimeConfig>(getRuntimeConfig())
|
||||
const { session } = useAuth()
|
||||
const [health, setHealth] = useState('')
|
||||
const [guilds, setGuilds] = useState<Array<{ id: string; name: string; slug: string }>>([])
|
||||
const [channelsByGuild, setChannelsByGuild] = useState<Record<string, Array<{ id: string; name: string }>>>({})
|
||||
|
||||
async function onSave(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setRuntimeConfig(form)
|
||||
resetApiClient()
|
||||
reconnectSocket()
|
||||
setHealth('配置已保存')
|
||||
const tokenByGuild = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const item of session?.guildAccessTokens ?? []) {
|
||||
map[item.guildNodeId] = item.token
|
||||
}
|
||||
return map
|
||||
}, [session])
|
||||
|
||||
async function checkHealth() {
|
||||
async function checkCenterHealth() {
|
||||
if (!session) return
|
||||
try {
|
||||
const res = await getApiClient().get('/healthz')
|
||||
const res = await axios.get(`${session.centerApiBase}/healthz`)
|
||||
setHealth(JSON.stringify(res.data))
|
||||
} catch {
|
||||
setHealth('healthz 访问失败')
|
||||
setHealth('center healthz 访问失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGuilds() {
|
||||
try {
|
||||
const res = await getApiClient().get('/guilds')
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
setGuilds(list)
|
||||
} catch {
|
||||
setGuilds([])
|
||||
}
|
||||
async function loadChannels(nodeId: string, endpoint: string) {
|
||||
const token = tokenByGuild[nodeId]
|
||||
if (!token) {
|
||||
setChannelsByGuild((prev) => ({ ...prev, [nodeId]: [] }))
|
||||
return
|
||||
}
|
||||
|
||||
async function loadChannels(guildId: string) {
|
||||
try {
|
||||
const res = await getApiClient().get('/channels', { params: { guildId } })
|
||||
const res = await axios.get(`${endpoint}/api/channels`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
setChannelsByGuild((prev) => ({ ...prev, [guildId]: list }))
|
||||
setChannelsByGuild((prev) => ({ ...prev, [nodeId]: list }))
|
||||
} catch {
|
||||
setChannelsByGuild((prev) => ({ ...prev, [guildId]: [] }))
|
||||
setChannelsByGuild((prev) => ({ ...prev, [nodeId]: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>工作台</h2>
|
||||
<form onSubmit={onSave} style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
|
||||
<input
|
||||
value={form.centerApiBase}
|
||||
onChange={(e) => setForm((v) => ({ ...v, centerApiBase: e.target.value }))}
|
||||
placeholder="Center API Base"
|
||||
/>
|
||||
<input
|
||||
value={form.guildApiBase}
|
||||
onChange={(e) => setForm((v) => ({ ...v, guildApiBase: e.target.value }))}
|
||||
placeholder="Guild API Base"
|
||||
/>
|
||||
<input
|
||||
value={form.guildSocketBase}
|
||||
onChange={(e) => setForm((v) => ({ ...v, guildSocketBase: e.target.value }))}
|
||||
placeholder="Guild Socket Base"
|
||||
/>
|
||||
<input
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((v) => ({ ...v, apiKey: e.target.value }))}
|
||||
placeholder="API Key"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit">保存配置</button>
|
||||
<button type="button" onClick={checkHealth}>
|
||||
测试 healthz
|
||||
<p>Center: {session?.centerApiBase || '-'}</p>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<button type="button" onClick={checkCenterHealth}>
|
||||
测试 Center healthz
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p>{health}</p>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button type="button" onClick={loadGuilds}>
|
||||
加载 Guild 列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{guilds.map((g) => (
|
||||
<li key={g.id} style={{ marginTop: 8 }}>
|
||||
<strong>{g.name}</strong> ({g.slug}){' '}
|
||||
<button type="button" onClick={() => loadChannels(g.id)}>
|
||||
{(session?.guilds ?? []).map((g) => (
|
||||
<li key={g.nodeId} style={{ marginTop: 8 }}>
|
||||
<strong>{g.name}</strong> ({g.nodeId}) - {g.endpoint}{' '}
|
||||
<button type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}>
|
||||
加载频道
|
||||
</button>
|
||||
<ul>
|
||||
{(channelsByGuild[g.id] ?? []).map((c) => (
|
||||
{(channelsByGuild[g.nodeId] ?? []).map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link to={`/chat?guildId=${encodeURIComponent(g.id)}&channelId=${encodeURIComponent(c.id)}`}>
|
||||
<Link
|
||||
to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`}
|
||||
>
|
||||
#{c.name}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user