feat(ui): BOTS section in member list

Members API appends server bots (is_bot). Sidebar groups ONLINE / OFFLINE / BOTS.
Bots get green BOT badge, no kick menu or profile. Mentions and DMs skip bots.
This commit is contained in:
2026-07-15 20:37:05 -04:00
parent 7bf1eaf845
commit fd7fa4a147
5 changed files with 63 additions and 16 deletions
+24
View File
@@ -35,6 +35,8 @@ type memberResponse struct {
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Status string `json:"status"` Status string `json:"status"`
StatusText string `json:"status_text"` StatusText string `json:"status_text"`
IsBot bool `json:"is_bot,omitempty"`
BotType string `json:"bot_type,omitempty"`
} }
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) { func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
@@ -82,6 +84,28 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members = append(members, m) members = append(members, m)
} }
// Bots added to this server (separate BOTS section in the member list).
botRows, err := h.db.QueryContext(r.Context(), `
SELECT b.id, b.name, COALESCE(b.avatar, ''), COALESCE(b.bot_type, '')
FROM bot_servers bs
JOIN bots b ON b.id = bs.bot_id
WHERE bs.server_id = $1
ORDER BY b.name
`, serverID)
if err == nil {
defer botRows.Close()
for botRows.Next() {
var m memberResponse
if err := botRows.Scan(&m.ID, &m.Username, &m.Avatar, &m.BotType); err != nil {
continue
}
m.DisplayName = m.Username
m.Status = "online" // ponytail: no bot presence yet; always show online
m.IsBot = true
members = append(members, m)
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members) json.NewEncoder(w).Encode(members)
} }
+7 -5
View File
@@ -326,7 +326,9 @@ export function ChatArea() {
const channels = activeServerId ? channelsByServer[activeServerId] || [] : []; const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
const activeChannel = channels.find((c) => c.id === activeChannelId); const activeChannel = channels.find((c) => c.id === activeChannelId);
const members = activeServerId ? membersByServer[activeServerId] || [] : []; const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]); // Humans only for mentions / nickname lookup (bots live in member list separately).
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]);
const markRead = useReadStatesStore((s) => s.markRead); const markRead = useReadStatesStore((s) => s.markRead);
const readStates = useReadStatesStore((s) => s.states); const readStates = useReadStatesStore((s) => s.states);
@@ -553,7 +555,7 @@ export function ChatArea() {
if (!isDropdownOpen) return; if (!isDropdownOpen) return;
const itemCount = mq !== null const itemCount = mq !== null
? members.filter((m) => ? humanMembers.filter((m) =>
m.username.toLowerCase().includes(mq.toLowerCase()) || m.username.toLowerCase().includes(mq.toLowerCase()) ||
m.display_name?.toLowerCase().includes(mq.toLowerCase()) m.display_name?.toLowerCase().includes(mq.toLowerCase())
).slice(0, 6).length ).slice(0, 6).length
@@ -574,7 +576,7 @@ export function ChatArea() {
e.stopPropagation(); e.stopPropagation();
if (mq !== null) { if (mq !== null) {
const q = mq.toLowerCase(); const q = mq.toLowerCase();
const filtered = members.filter((m) => const filtered = humanMembers.filter((m) =>
m.username.toLowerCase().includes(q) || m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q) m.display_name?.toLowerCase().includes(q)
).slice(0, 6); ).slice(0, 6);
@@ -618,7 +620,7 @@ export function ChatArea() {
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]); }, [humanMembers, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (mentionQuery !== null || commandQuery !== null) return; if (mentionQuery !== null || commandQuery !== null) return;
@@ -902,7 +904,7 @@ export function ChatArea() {
)} )}
<div className="p-3 relative"> <div className="p-3 relative">
{mentionQuery !== null && ( {mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} /> <MentionDropdown query={mentionQuery} members={humanMembers} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)} )}
{commandQuery !== null && ( {commandQuery !== null && (
<CommandDropdown <CommandDropdown
+29 -11
View File
@@ -57,7 +57,7 @@ interface MemberRowProps {
function MemberRow({ member, onProfileClick }: MemberRowProps) { function MemberRow({ member, onProfileClick }: MemberRowProps) {
const presence = usePresenceStore((s) => s.presences[member.id]); const presence = usePresenceStore((s) => s.presences[member.id]);
const status = presence?.status ?? member.status; const status = member.is_bot ? "online" : (presence?.status ?? member.status);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const activeServerId = useServerStore((s) => s.activeServerId); const activeServerId = useServerStore((s) => s.activeServerId);
@@ -72,14 +72,21 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
return ( return (
<div className="relative"> <div className="relative">
<div <div
onClick={() => onProfileClick(member.id)} onClick={() => {
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group" if (!member.is_bot) onProfileClick(member.id);
}}
className={`w-full flex items-center gap-2 px-2 py-1 text-left group ${
member.is_bot ? "cursor-default" : "hover:bg-gb-bg-t cursor-pointer"
}`}
> >
<span className={statusColor(status)}>{statusIcon(status)}</span> <span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)} flex-1`}> <span className={`truncate ${member.is_bot ? "text-gb-green" : usernameColor(status)} flex-1`}>
{member.nickname || member.display_name || member.username} {member.nickname || member.display_name || member.username}
</span> </span>
{!isSelf && ( {member.is_bot && (
<span className="text-gb-bg bg-gb-green px-0.5 font-mono text-[10px] shrink-0">BOT</span>
)}
{!isSelf && !member.is_bot && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -92,7 +99,7 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
</button> </button>
)} )}
</div> </div>
{menuOpen && !isSelf && activeServerId && ( {menuOpen && !isSelf && !member.is_bot && activeServerId && (
<MemberContextMenu <MemberContextMenu
memberId={member.id} memberId={member.id}
username={member.username} username={member.username}
@@ -155,11 +162,13 @@ export function MemberList() {
} }
}, [activeServerId, fetchMembers]); }, [activeServerId, fetchMembers]);
const online = members.filter((m) => { const humans = members.filter((m) => !m.is_bot);
const bots = members.filter((m) => m.is_bot);
const online = humans.filter((m) => {
const status = presences[m.id]?.status ?? m.status; const status = presences[m.id]?.status ?? m.status;
return status !== "offline"; return status !== "offline";
}); });
const offline = members.filter((m) => { const offline = humans.filter((m) => {
const status = presences[m.id]?.status ?? m.status; const status = presences[m.id]?.status ?? m.status;
return status === "offline"; return status === "offline";
}); });
@@ -178,7 +187,7 @@ export function MemberList() {
)} )}
{online.length > 0 && ( {online.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div> <div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE {online.length}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((m) => ( {online.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} /> <MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
@@ -186,14 +195,23 @@ export function MemberList() {
</div> </div>
)} )}
{offline.length > 0 && ( {offline.length > 0 && (
<div> <div className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div> <div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE {offline.length}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div> <div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((m) => ( {offline.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} /> <MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))} ))}
</div> </div>
)} )}
{bots.length > 0 && (
<div>
<div className="text-gb-fg-t text-xs uppercase mb-1">BOTS {bots.length}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{bots.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))}
</div>
)}
</div> </div>
{profileUserId && ( {profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} /> <UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
@@ -33,6 +33,7 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
const q = query.toLowerCase(); const q = query.toLowerCase();
return members.filter( return members.filter(
(m) => (m) =>
!m.is_bot &&
m.id !== currentUserId && m.id !== currentUserId &&
(m.username.toLowerCase().includes(q) || (m.username.toLowerCase().includes(q) ||
(m.display_name || "").toLowerCase().includes(q)), (m.display_name || "").toLowerCase().includes(q)),
+2
View File
@@ -9,6 +9,8 @@ export interface Member {
avatar: string; avatar: string;
status: "online" | "idle" | "dnd" | "offline"; status: "online" | "idle" | "dnd" | "offline";
status_text: string; status_text: string;
is_bot?: boolean;
bot_type?: string;
} }
interface MemberState { interface MemberState {