feat(frontend): public channels, self-excluded member picker, editable display name
- create-channel modal: Public checkbox (default off), self excluded from member list (creator auto-added), shows member display names - Settings modal to edit your own display name - members panel shows names and marks (you) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
|||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
|
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
|
||||||
import type { AuthSession } from '../lib/auth-storage'
|
import type { AuthSession } from '../lib/auth-storage'
|
||||||
import { loginCenter, logoutCenter, meGuildsCenter, refreshCenter } from '../lib/center-auth-client'
|
import { loginCenter, logoutCenter, meGuildsCenter, refreshCenter, updateMeNameCenter } from '../lib/center-auth-client'
|
||||||
import { AuthContext } from './auth-context'
|
import { AuthContext } from './auth-context'
|
||||||
import type { AuthContextValue } from './auth-context'
|
import type { AuthContextValue } from './auth-context'
|
||||||
|
|
||||||
@@ -72,6 +72,22 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
setAuthSession(next)
|
setAuthSession(next)
|
||||||
setSession(next)
|
setSession(next)
|
||||||
},
|
},
|
||||||
|
updateName: async (name: string) => {
|
||||||
|
if (!session) return
|
||||||
|
const accessToken = (await (async () => {
|
||||||
|
if (!isAccessTokenStale(session.accessToken)) return session.accessToken
|
||||||
|
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
|
||||||
|
return refreshed.accessToken
|
||||||
|
})())
|
||||||
|
const updated = await updateMeNameCenter(session.centerApiBase, accessToken, name)
|
||||||
|
const next: AuthSession = {
|
||||||
|
...session,
|
||||||
|
accessToken,
|
||||||
|
user: { ...session.user, name: updated.name },
|
||||||
|
}
|
||||||
|
setAuthSession(next)
|
||||||
|
setSession(next)
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[session],
|
[session],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type AuthContextValue = {
|
|||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
ensureFreshToken: () => Promise<string | null>
|
ensureFreshToken: () => Promise<string | null>
|
||||||
refreshGuilds: () => Promise<void>
|
refreshGuilds: () => Promise<void>
|
||||||
|
updateName: (name: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AuthSession = {
|
|||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
guilds: Array<{
|
guilds: Array<{
|
||||||
nodeId: string
|
nodeId: string
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type LoginResponse = {
|
|||||||
refreshToken: string
|
refreshToken: string
|
||||||
tokenType: string
|
tokenType: string
|
||||||
expiresIn?: number
|
expiresIn?: number
|
||||||
user: { id: string; email: string }
|
user: { id: string; email: string; name: string }
|
||||||
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
|
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
|
||||||
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
|
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
|
||||||
}
|
}
|
||||||
@@ -74,10 +74,23 @@ export async function guildMembersCenter(
|
|||||||
centerApiBase: string,
|
centerApiBase: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
guildNodeId: string,
|
guildNodeId: string,
|
||||||
): Promise<Array<{ userId: string; email: string; status: string }>> {
|
): Promise<Array<{ userId: string; email: string; name: string; status: string }>> {
|
||||||
const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; status: string }>>(
|
const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; name: string; status: string }>>(
|
||||||
`/auth/guilds/${encodeURIComponent(guildNodeId)}/members`,
|
`/auth/guilds/${encodeURIComponent(guildNodeId)}/members`,
|
||||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
)
|
)
|
||||||
return Array.isArray(res.data) ? res.data : []
|
return Array.isArray(res.data) ? res.data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMeNameCenter(
|
||||||
|
centerApiBase: string,
|
||||||
|
accessToken: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<{ id: string; email: string; name: string }> {
|
||||||
|
const res = await centerClient(centerApiBase).patch<{ id: string; email: string; name: string }>(
|
||||||
|
'/auth/me',
|
||||||
|
{ name },
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ type MessageItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GuildChannel = { id: string; name: string; guildId?: string }
|
type GuildChannel = { id: string; name: string; guildId?: string }
|
||||||
type MemberItem = { userId: string; email: string; status: string }
|
type MemberItem = { userId: string; email: string; name: string; status: string }
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { session, logout, ensureFreshToken, refreshGuilds } = useAuth()
|
const { session, logout, ensureFreshToken, refreshGuilds, updateName } = useAuth()
|
||||||
const [selectedGuildId, setSelectedGuildId] = useState('')
|
const [selectedGuildId, setSelectedGuildId] = useState('')
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState('')
|
const [selectedChannelId, setSelectedChannelId] = useState('')
|
||||||
const [channels, setChannels] = useState<GuildChannel[]>([])
|
const [channels, setChannels] = useState<GuildChannel[]>([])
|
||||||
@@ -26,7 +26,10 @@ export default function ChatPage() {
|
|||||||
const [newChannelName, setNewChannelName] = useState('')
|
const [newChannelName, setNewChannelName] = useState('')
|
||||||
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
|
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
|
||||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
|
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
|
||||||
|
const [newChannelPublic, setNewChannelPublic] = useState(false)
|
||||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
|
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
|
||||||
|
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||||
|
const [settingsName, setSettingsName] = useState('')
|
||||||
const [showGuilds, setShowGuilds] = useState(true)
|
const [showGuilds, setShowGuilds] = useState(true)
|
||||||
const [showChannels, setShowChannels] = useState(true)
|
const [showChannels, setShowChannels] = useState(true)
|
||||||
const [showMembers, setShowMembers] = useState(true)
|
const [showMembers, setShowMembers] = useState(true)
|
||||||
@@ -153,12 +156,14 @@ export default function ChatPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: newChannelName.trim(),
|
name: newChannelName.trim(),
|
||||||
guildId: effectiveGuildId,
|
guildId: effectiveGuildId,
|
||||||
|
isPublic: newChannelPublic,
|
||||||
memberUserIds: selectedMemberIds,
|
memberUserIds: selectedMemberIds,
|
||||||
}
|
}
|
||||||
const res = await guildApi().post('/channels', payload)
|
const res = await guildApi().post('/channels', payload)
|
||||||
const createdId = res.data?.id as string | undefined
|
const createdId = res.data?.id as string | undefined
|
||||||
setNewChannelName('')
|
setNewChannelName('')
|
||||||
setSelectedMemberIds([])
|
setSelectedMemberIds([])
|
||||||
|
setNewChannelPublic(false)
|
||||||
setShowCreateChannelModal(false)
|
setShowCreateChannelModal(false)
|
||||||
await loadChannels()
|
await loadChannels()
|
||||||
if (createdId) setSelectedChannelId(createdId)
|
if (createdId) setSelectedChannelId(createdId)
|
||||||
@@ -167,6 +172,21 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
const trimmed = settingsName.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Name must not be empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateName(trimmed)
|
||||||
|
setShowSettingsModal(false)
|
||||||
|
await loadMembers()
|
||||||
|
} catch {
|
||||||
|
setError('Failed to update name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadMembers()
|
void loadMembers()
|
||||||
setSelectedMemberIds([])
|
setSelectedMemberIds([])
|
||||||
@@ -263,7 +283,12 @@ export default function ChatPage() {
|
|||||||
<h3>Members</h3>
|
<h3>Members</h3>
|
||||||
<ul className="list-reset">
|
<ul className="list-reset">
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<li key={m.userId}><span className="muted">{m.email}</span></li>
|
<li key={m.userId}>
|
||||||
|
<span className="muted">
|
||||||
|
{m.name || m.email}
|
||||||
|
{m.userId === session?.user.id ? ' (you)' : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +299,16 @@ export default function ChatPage() {
|
|||||||
<button className="btn btn-secondary" onClick={() => setShowChannels((v) => !v)}>{showChannels ? 'Hide v2' : 'Show v2'}</button>
|
<button className="btn btn-secondary" onClick={() => setShowChannels((v) => !v)}>{showChannels ? 'Hide v2' : 'Show v2'}</button>
|
||||||
<button className="btn btn-secondary" onClick={() => setShowMembers((v) => !v)}>{showMembers ? 'Hide v4' : 'Show v4'}</button>
|
<button className="btn btn-secondary" onClick={() => setShowMembers((v) => !v)}>{showMembers ? 'Hide v4' : 'Show v4'}</button>
|
||||||
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
|
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
|
||||||
<button className="btn btn-secondary" type="button">Settings</button>
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsName(session?.user.name ?? '')
|
||||||
|
setShowSettingsModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreateChannelModal ? (
|
{showCreateChannelModal ? (
|
||||||
@@ -282,12 +316,25 @@ export default function ChatPage() {
|
|||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>Create channel</h3>
|
<h3>Create channel</h3>
|
||||||
<input className="input" value={newChannelName} onChange={(e) => setNewChannelName(e.target.value)} placeholder="Channel name" />
|
<input className="input" value={newChannelName} onChange={(e) => setNewChannelName(e.target.value)} placeholder="Channel name" />
|
||||||
<p className="muted" style={{ marginTop: 8 }}>Select members to include</p>
|
<label className="row-wrap" style={{ alignItems: 'center', marginTop: 8 }}>
|
||||||
|
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
|
||||||
|
<span>Public (visible to all guild members)</span>
|
||||||
|
</label>
|
||||||
|
<p className="muted" style={{ marginTop: 8 }}>
|
||||||
|
{newChannelPublic ? 'You are added automatically; all guild members can see it.' : 'You are added automatically. Select members to include'}
|
||||||
|
</p>
|
||||||
<div className="modal-list">
|
<div className="modal-list">
|
||||||
{members.map((m) => (
|
{members
|
||||||
|
.filter((m) => m.userId !== session?.user.id)
|
||||||
|
.map((m) => (
|
||||||
<label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}>
|
<label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}>
|
||||||
<input type="checkbox" checked={selectedMemberIds.includes(m.userId)} onChange={() => toggleMember(m.userId)} />
|
<input
|
||||||
<span>{m.email}</span>
|
type="checkbox"
|
||||||
|
checked={selectedMemberIds.includes(m.userId)}
|
||||||
|
disabled={newChannelPublic}
|
||||||
|
onChange={() => toggleMember(m.userId)}
|
||||||
|
/>
|
||||||
|
<span>{m.name || m.email}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,6 +346,26 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showSettingsModal ? (
|
||||||
|
<div className="modal-backdrop" onClick={() => setShowSettingsModal(false)}>
|
||||||
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<p className="muted">Signed in as {session?.user.email}</p>
|
||||||
|
<p className="muted" style={{ marginTop: 8 }}>Display name</p>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={settingsName}
|
||||||
|
onChange={(e) => setSettingsName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
<div className="row-wrap" style={{ marginTop: 12 }}>
|
||||||
|
<button className="btn" onClick={saveName}>Save</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowSettingsModal(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="modal-backdrop" onClick={() => setError('')}>
|
<div className="modal-backdrop" onClick={() => setError('')}>
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user