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 038ac1fe8e
commit a277c78e2c
5 changed files with 63 additions and 16 deletions
+24
View File
@@ -35,6 +35,8 @@ type memberResponse struct {
Avatar string `json:"avatar"`
Status string `json:"status"`
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) {
@@ -82,6 +84,28 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
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")
json.NewEncoder(w).Encode(members)
}
+7 -5
View File
@@ -326,7 +326,9 @@ export function ChatArea() {
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
const activeChannel = channels.find((c) => c.id === activeChannelId);
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 readStates = useReadStatesStore((s) => s.states);
@@ -553,7 +555,7 @@ export function ChatArea() {
if (!isDropdownOpen) return;
const itemCount = mq !== null
? members.filter((m) =>
? humanMembers.filter((m) =>
m.username.toLowerCase().includes(mq.toLowerCase()) ||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
).slice(0, 6).length
@@ -574,7 +576,7 @@ export function ChatArea() {
e.stopPropagation();
if (mq !== null) {
const q = mq.toLowerCase();
const filtered = members.filter((m) =>
const filtered = humanMembers.filter((m) =>
m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q)
).slice(0, 6);
@@ -618,7 +620,7 @@ export function ChatArea() {
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
}, [humanMembers, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = useCallback(async () => {
if (mentionQuery !== null || commandQuery !== null) return;
@@ -902,7 +904,7 @@ export function ChatArea() {
)}
<div className="p-3 relative">
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
<MentionDropdown query={mentionQuery} members={humanMembers} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)}
{commandQuery !== null && (
<CommandDropdown
+29 -11
View File
@@ -57,7 +57,7 @@ interface MemberRowProps {
function MemberRow({ member, onProfileClick }: MemberRowProps) {
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 currentUser = useAuthStore((s) => s.user);
const activeServerId = useServerStore((s) => s.activeServerId);
@@ -72,14 +72,21 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
return (
<div className="relative">
<div
onClick={() => onProfileClick(member.id)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group"
onClick={() => {
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={`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}
</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
onClick={(e) => {
e.stopPropagation();
@@ -92,7 +99,7 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
</button>
)}
</div>
{menuOpen && !isSelf && activeServerId && (
{menuOpen && !isSelf && !member.is_bot && activeServerId && (
<MemberContextMenu
memberId={member.id}
username={member.username}
@@ -155,11 +162,13 @@ export function MemberList() {
}
}, [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;
return status !== "offline";
});
const offline = members.filter((m) => {
const offline = humans.filter((m) => {
const status = presences[m.id]?.status ?? m.status;
return status === "offline";
});
@@ -178,7 +187,7 @@ export function MemberList() {
)}
{online.length > 0 && (
<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>
{online.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
@@ -186,14 +195,23 @@ export function MemberList() {
</div>
)}
{offline.length > 0 && (
<div>
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="mb-3">
<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>
{offline.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))}
</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>
{profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
@@ -33,6 +33,7 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
const q = query.toLowerCase();
return members.filter(
(m) =>
!m.is_bot &&
m.id !== currentUserId &&
(m.username.toLowerCase().includes(q) ||
(m.display_name || "").toLowerCase().includes(q)),
+2
View File
@@ -9,6 +9,8 @@ export interface Member {
avatar: string;
status: "online" | "idle" | "dnd" | "offline";
status_text: string;
is_bot?: boolean;
bot_type?: string;
}
interface MemberState {