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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user