diff --git a/internal/server/members.go b/internal/server/members.go index 06b0db8..83a3abe 100644 --- a/internal/server/members.go +++ b/internal/server/members.go @@ -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) } diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 4b70e50..bd672ca 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -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() { )}
{mentionQuery !== null && ( - + )} {commandQuery !== null && ( 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 (
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" + }`} > {statusIcon(status)} - + {member.nickname || member.display_name || member.username} - {!isSelf && ( + {member.is_bot && ( + BOT + )} + {!isSelf && !member.is_bot && ( )}
- {menuOpen && !isSelf && activeServerId && ( + {menuOpen && !isSelf && !member.is_bot && activeServerId && ( { + 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 && (
-
ONLINE
+
ONLINE — {online.length}
---
{online.map((m) => ( @@ -186,14 +195,23 @@ export function MemberList() {
)} {offline.length > 0 && ( -
-
OFFLINE
+
+
OFFLINE — {offline.length}
---
{offline.map((m) => ( ))}
)} + {bots.length > 0 && ( +
+
BOTS — {bots.length}
+
---
+ {bots.map((m) => ( + + ))} +
+ )}
{profileUserId && ( setProfileUserId(null)} /> diff --git a/web/src/components/NewConversationModal.tsx b/web/src/components/NewConversationModal.tsx index 1bb9cf3..253e41e 100644 --- a/web/src/components/NewConversationModal.tsx +++ b/web/src/components/NewConversationModal.tsx @@ -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)), diff --git a/web/src/stores/member.ts b/web/src/stores/member.ts index ac4baa0..ec9f9b3 100644 --- a/web/src/stores/member.ts +++ b/web/src/stores/member.ts @@ -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 {