fix: role logic, autofocus

- MessageInput: autofocus on reply
- usePermission: new hook to start working out permissions and actions
- fix all build blocking errors
This commit is contained in:
2025-10-06 20:03:49 -04:00
parent 99ade46247
commit 24a99900b1
6 changed files with 274 additions and 125 deletions

View File

@@ -73,11 +73,11 @@ const ChannelSidebar: React.FC = () => {
{/* Bottom Actions */} {/* Bottom Actions */}
<div className="border-t border-sidebar px-2 py-2"> <div className="border-t border-sidebar px-2 py-2">
<div className="flex items-center justify-between"> <div className="flex items-center">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="interactive-hover" className="justify-start interactive-hover flex-grow-1"
onClick={openCreateChannel} onClick={openCreateChannel}
> >
<Plus size={16} className="mr-1" /> <Plus size={16} className="mr-1" />
@@ -87,7 +87,7 @@ const ChannelSidebar: React.FC = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`h-8 w-8 ${showMemberList ? "text-interactive-active" : "interactive-hover"}`} className={`h-8 w-8 flex-shrink-0 ${showMemberList ? "text-interactive-active" : "interactive-hover"}`}
onClick={toggleMemberList} onClick={toggleMemberList}
> >
<Users size={16} /> <Users size={16} />

View File

@@ -4,12 +4,10 @@ import { Crown, Shield, UserIcon } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Role } from "@/types/database"; import { Role, User } from "@/types/database";
import { useInstanceMembers } from "@/hooks/useServers"; import { useInstanceMembers } from "@/hooks/useServers";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { User } from "@/types/database";
// Status color utility
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case "online": case "online":
@@ -27,23 +25,30 @@ interface MemberItemProps {
member: User; member: User;
instanceId: string; instanceId: string;
isOwner?: boolean; isOwner?: boolean;
currentUserRole: "member" | "mod" | "admin"; currentUserRolePriority: number;
} }
// Get the user's role for this specific instance const getUserRoleForInstance = (roles: Role[], instanceId: string): string => {
const getUserRoleForInstance = (roles: Role[], instanceId: string) => { if (!instanceId) return "member";
return roles.find((r) => r.instanceId === instanceId)?.role || "member"; const roleEntry = roles.find((r) => r.instanceId === instanceId);
return roleEntry?.role || "member";
}; };
// Define role colors and priorities
const getRoleInfo = (role: string) => { const getRoleInfo = (role: string) => {
switch (role) { const lowerRole = role.toLowerCase();
switch (lowerRole) {
case "admin": case "admin":
return { color: "#ff6b6b", priority: 3, name: "Admin" }; return { color: "#ff6b6b", priority: 3, name: "Admin" };
case "mod": case "mod":
return { color: "#4ecdc4", priority: 2, name: "Moderator" }; return { color: "#4ecdc4", priority: 2, name: "Moderator" };
default: case "member":
return { color: null, priority: 1, name: "Member" }; return { color: null, priority: 1, name: "Member" };
default:
return {
color: null,
priority: 0,
name: role.charAt(0).toUpperCase() + role.slice(1),
};
} }
}; };
@@ -51,16 +56,29 @@ const MemberItem: React.FC<MemberItemProps> = ({
member, member,
instanceId, instanceId,
isOwner = false, isOwner = false,
currentUserRolePriority,
}) => { }) => {
const userRole = getUserRoleForInstance(member.roles, instanceId || ""); // Determine the role for this specific instance
const userRole = getUserRoleForInstance(member.roles, instanceId);
const roleInfo = getRoleInfo(userRole); const roleInfo = getRoleInfo(userRole);
const memberRolePriority = roleInfo.priority;
// Consider if this member is a global admin as well
const isGlobalAdmin = member.admin || false;
let effectiveRoleInfo = roleInfo;
let effectiveMemberRolePriority = memberRolePriority;
if (isGlobalAdmin && roleInfo.priority < 3) {
effectiveRoleInfo = getRoleInfo("admin");
effectiveMemberRolePriority = 3;
}
return ( return (
<>
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50" className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
disabled={member.admin} // disable if the current member is an admin
disabled={currentUserRolePriority < 3 || effectiveMemberRolePriority >= 3}
> >
<div className="flex items-center gap-3 w-full"> <div className="flex items-center gap-3 w-full">
<div className="relative"> <div className="relative">
@@ -70,7 +88,7 @@ const MemberItem: React.FC<MemberItemProps> = ({
alt={member.username} alt={member.username}
/> />
<AvatarFallback className="text-xs bg-primary text-primary-foreground"> <AvatarFallback className="text-xs bg-primary text-primary-foreground">
{member.username.slice(0, 2).toUpperCase()} {member.username?.slice(0, 2).toUpperCase() || "???"}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{/* Status indicator */} {/* Status indicator */}
@@ -84,16 +102,21 @@ const MemberItem: React.FC<MemberItemProps> = ({
{isOwner && ( {isOwner && (
<Crown size={12} className="text-yellow-500 flex-shrink-0" /> <Crown size={12} className="text-yellow-500 flex-shrink-0" />
)} )}
{!isOwner && userRole !== "member" && ( {/* Display Shield for Admins and Mods, not for Members */}
{!isOwner && effectiveMemberRolePriority > 1 && (
<Shield <Shield
size={12} size={12}
className="flex-shrink-0" className="flex-shrink-0"
style={{ color: roleInfo.color || "var(--background)" }} style={{
color: effectiveRoleInfo.color || "var(--background)",
}}
/> />
)} )}
<span <span
className="text-sm font-medium truncate" className="text-sm font-medium truncate"
style={{ color: roleInfo.color || "var(--color-text-primary)" }} style={{
color: effectiveRoleInfo.color || "var(--color-text-primary)",
}}
> >
{member.nickname || member.username} {member.nickname || member.username}
</span> </span>
@@ -106,23 +129,26 @@ const MemberItem: React.FC<MemberItemProps> = ({
</div> </div>
</div> </div>
</Button> </Button>
</>
); );
}; };
const MemberList: React.FC = () => { const MemberList: React.FC = () => {
const { instanceId } = useParams(); const { instanceId } = useParams<{ instanceId: string }>();
const { data: members, isLoading } = useInstanceMembers(instanceId); const { data: members, isLoading } = useInstanceMembers(instanceId);
const { user: currentUser } = useAuthStore(); const { user: currentUser } = useAuthStore();
const currentUserRole = React.useMemo(() => { const currentUserRoleInfo = React.useMemo(() => {
if (!currentUser || !instanceId) return "member"; if (!currentUser || !instanceId) {
if (currentUser.admin) return "admin"; return { role: "member", priority: 1, name: "Member", color: null };
}
const userRole = currentUser.roles.find( // If the current user is a global admin, they are effectively an admin of any instance.
(role) => role.instanceId === instanceId, if (currentUser.admin) {
); return { role: "admin", priority: 3, name: "Admin", color: "#ff6b6b" };
return userRole?.role || "member"; }
const role = getUserRoleForInstance(currentUser.roles, instanceId);
return { ...getRoleInfo(role), role: role };
}, [currentUser, instanceId]); }, [currentUser, instanceId]);
if (!instanceId) { if (!instanceId) {
@@ -140,17 +166,26 @@ const MemberList: React.FC = () => {
if (!members || members.length === 0) { if (!members || members.length === 0) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-concord-secondary text-sm">No members</div> <div className="text-concord-secondary text-sm">No members found</div>
</div> </div>
); );
} }
// Group members by role // Group members by their role for the current instance.
const groupedMembers = members.reduce( const groupedMembers = members.reduce(
(acc, member) => { (acc, member) => {
const userRole = // Determine the effective role for this instance.
member.roles.find((r) => r.instanceId === instanceId)?.role || "member"; let effectiveRoleName = getUserRoleForInstance(
const roleInfo = getRoleInfo(userRole); member.roles as Role[],
instanceId,
);
// Global admin is instance admin
if (member.admin && effectiveRoleName !== "admin") {
effectiveRoleName = "admin";
}
const roleInfo = getRoleInfo(effectiveRoleName);
if (!acc[roleInfo.name]) { if (!acc[roleInfo.name]) {
acc[roleInfo.name] = []; acc[roleInfo.name] = [];
@@ -161,11 +196,11 @@ const MemberList: React.FC = () => {
{} as Record<string, User[]>, {} as Record<string, User[]>,
); );
// Sort role groups by priority (admin > mod > member) // Get all unique role names present and sort them by priority.
const sortedRoleGroups = Object.entries(groupedMembers).sort( const sortedRoleNames = Object.keys(groupedMembers).sort(
([roleNameA], [roleNameB]) => { (roleNameA, roleNameB) => {
const priorityA = getRoleInfo(roleNameA.toLowerCase())?.priority || 1; const priorityA = getRoleInfo(roleNameA).priority;
const priorityB = getRoleInfo(roleNameB.toLowerCase())?.priority || 1; const priorityB = getRoleInfo(roleNameB).priority;
return priorityB - priorityA; return priorityB - priorityA;
}, },
); );
@@ -174,16 +209,30 @@ const MemberList: React.FC = () => {
<div className="flex flex-col flex-1 border-l border-concord-primary h-full bg-concord-secondary"> <div className="flex flex-col flex-1 border-l border-concord-primary h-full bg-concord-secondary">
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between"> <div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
<UserIcon size={20} className="text-concord-primary h-8" /> <div className="flex items-center gap-2">
<p className="text-sm font-semibold text-concord-secondary tracking-wide"> <UserIcon size={20} className="text-concord-primary" />
{members.length} Members <p className="text-sm font-semibold text-concord-primary tracking-wide">
Members
</p>
</div>
<p className="text-sm font-medium text-concord-secondary tracking-wide">
{members.length}
</p> </p>
</div> </div>
{/* Member List */} {/* Member List */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="py-2"> <div className="py-2">
{sortedRoleGroups.map(([roleName, roleMembers]) => ( {sortedRoleNames.map((roleName) => {
const roleMembers = groupedMembers[roleName];
// Sort members within each role group alphabetically by username.
const sortedMembers = roleMembers.sort((a, b) =>
(a.nickname || a.username).localeCompare(
b.nickname || b.username,
),
);
return (
<div key={roleName} className="mb-4"> <div key={roleName} className="mb-4">
{/* Role Header */} {/* Role Header */}
<div className="px-4 py-1"> <div className="px-4 py-1">
@@ -194,20 +243,19 @@ const MemberList: React.FC = () => {
{/* Role Members */} {/* Role Members */}
<div className="space-y-1"> <div className="space-y-1">
{roleMembers {sortedMembers.map((member) => (
.sort((a, b) => a.username.localeCompare(b.username))
.map((member) => (
<MemberItem <MemberItem
key={member.id} key={member.id}
member={member} member={member}
instanceId={instanceId} instanceId={instanceId}
currentUserRole={currentUserRole} currentUserRolePriority={currentUserRoleInfo.priority}
isOwner={false} isOwner={false}
/> />
))} ))}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>

View File

@@ -16,7 +16,7 @@ import { useState } from "react";
interface MessageUser { interface MessageUser {
id: string; id: string;
username?: string; username?: string;
nickName?: string | null; nickname?: string | null;
picture?: string | null; picture?: string | null;
} }
@@ -64,8 +64,8 @@ export const MessageComponent: React.FC<MessageProps> = ({
const { mode } = useTheme(); const { mode } = useTheme();
// Get username with fallback // Get username with fallback
const username = user.username || user.userName || "Unknown User"; const username = user.username || user.username || "Unknown User";
const displayName = user.nickname || user.nickName || username; const displayName = user.nickname || user.nickname || username;
const isDeleted = message.deleted; const isDeleted = message.deleted;
@@ -109,10 +109,7 @@ export const MessageComponent: React.FC<MessageProps> = ({
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary"> <div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" /> <div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary"> <span className="font-medium text-concord-primary">
{replyToUser.nickname || {replyToUser.nickname || replyToUser.username}
replyToUser.nickName ||
replyToUser.username ||
replyToUser.userName}
</span> </span>
<span className="truncate max-w-xs opacity-75"> <span className="truncate max-w-xs opacity-75">
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")} {replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}

View File

@@ -1,12 +1,14 @@
// src/components/message/MessageInput.tsx
import { Message } from "@/lib/api-client"; import { Message } from "@/lib/api-client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react"; // Keep useRef
import { useSendMessage } from "@/hooks/useMessages"; import { useSendMessage } from "@/hooks/useMessages";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
interface MessageUser { interface MessageUser {
id: string; id: string;
username?: string; username?: string;
nickName?: string | null; nickname?: string | null;
picture?: string | null; picture?: string | null;
} }
interface MessageInputProps { interface MessageInputProps {
@@ -15,7 +17,6 @@ interface MessageInputProps {
replyingTo?: Message | null; replyingTo?: Message | null;
onCancelReply?: () => void; onCancelReply?: () => void;
replyingToUser: MessageUser | null; replyingToUser: MessageUser | null;
messageInputRef: React.RefObject<HTMLInputElement>;
} }
export const MessageInput: React.FC<MessageInputProps> = ({ export const MessageInput: React.FC<MessageInputProps> = ({
@@ -24,22 +25,26 @@ export const MessageInput: React.FC<MessageInputProps> = ({
replyingTo, replyingTo,
onCancelReply, onCancelReply,
replyingToUser, replyingToUser,
messageInputRef,
}) => { }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const textareaRef = messageInputRef; const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages // Use the API hook for sending messages
const sendMessageMutation = useSendMessage(); const sendMessageMutation = useSendMessage();
// Auto-resize textarea // Auto-resize textarea and focus when replying
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = "auto"; textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
// Focus the input when a reply is initiated
if (replyingTo) {
textareaRef.current.focus();
} }
}, [content]); }
}, [content, replyingTo]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -73,7 +78,7 @@ export const MessageInput: React.FC<MessageInputProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" /> <div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary"> <span className="font-medium text-concord-primary">
{replyingToUser.nickname || replyingToUser.userName} {replyingToUser.nickname || replyingToUser.username}
</span> </span>
</div> </div>
<Button <Button

View File

@@ -0,0 +1,108 @@
import { useAuthStore } from "@/stores/authStore";
import { Role } from "@/types/database";
import { useMemo } from "react";
import { useParams } from "react-router";
type PermissionsRole = "admin" | "member" | "mod";
const getUserRoleForInstance = (
roles: Role[],
instanceId: string,
): PermissionsRole => {
if (!instanceId) return "member";
const roleEntry = roles.find((r) => r.instanceId === instanceId);
return roleEntry?.role || "member";
};
const getRoleInfo = (role: PermissionsRole) => {
const lowerRole = role.toLowerCase();
switch (lowerRole) {
case "admin":
return { color: "#ff6b6b", priority: 3, name: "Admin" };
case "mod":
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
case "member":
return { color: null, priority: 1, name: "Member" };
default:
return {
color: null,
priority: 0,
name: role.charAt(0).toUpperCase() + role.slice(1),
};
}
};
interface InstancePermissions {
currentUserRole: PermissionsRole;
currentUserRolePriority: number;
canManageMembers: boolean; // Can kick/ban/promote/demote members
canViewAdminPanel: boolean;
}
export const useInstancePermissions = (): InstancePermissions => {
const { instanceId } = useParams<{ instanceId: string }>();
const { user: currentUser } = useAuthStore();
const permissions = useMemo(() => {
let currentUserRole: PermissionsRole = "member";
let currentUserRolePriority = 1;
let canManageMembers = false;
let canViewAdminPanel = false;
if (!currentUser || !instanceId) {
// If no user or instance, user has no permissions within an instance
return {
currentUserRole,
currentUserRolePriority,
canManageMembers,
canViewAdminPanel,
};
}
// If they are a global admin
if (currentUser.admin) {
currentUserRole = "admin";
currentUserRolePriority = 3;
canManageMembers = true;
canViewAdminPanel = true;
return {
currentUserRole: "admin",
currentUserRolePriority: 3,
canManageMembers: true,
canManageRoles: true,
canViewAdminPanel: true,
};
}
// Instance-Specific Role Check
const instanceRole = getUserRoleForInstance(currentUser.roles, instanceId);
const roleInfo = getRoleInfo(instanceRole as PermissionsRole);
currentUserRole = instanceRole;
currentUserRolePriority = roleInfo.priority;
// Define permissions based on role priority
if (roleInfo.priority >= 3) {
// Admin
canManageMembers = true;
canViewAdminPanel = true;
} else if (roleInfo.priority === 2) {
// Moderator
canManageMembers = true;
canViewAdminPanel = false;
} else {
// Member (priority 1 or 0)
canManageMembers = false;
canViewAdminPanel = false;
}
return {
currentUserRole,
currentUserRolePriority,
canManageMembers,
canViewAdminPanel,
};
}, [currentUser, instanceId]);
return permissions as InstancePermissions;
};

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Hash, Volume2, Users, Plus } from "lucide-react"; import { Hash, Volume2, Users } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -15,7 +15,6 @@ import { MessageInput } from "@/components/message/MessageInput";
const ChatPage: React.FC = () => { const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams(); const { instanceId, channelId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const messageInputRef = useRef<HTMLInputElement>(null);
const { const {
data: instance, data: instance,
@@ -105,13 +104,6 @@ const ChatPage: React.FC = () => {
[channelMessages], [channelMessages],
); );
// Focus the message input
useEffect(() => {
if (messageInputRef.current) {
messageInputRef.current.focus();
}
}, [handleReply]);
// Effect for scroll to top and load more // Effect for scroll to top and load more
useEffect(() => { useEffect(() => {
const scrollAreaElement = scrollAreaRef.current; const scrollAreaElement = scrollAreaRef.current;
@@ -332,7 +324,6 @@ const ChatPage: React.FC = () => {
? users?.find((u) => u.id === replyingTo.userId) || null ? users?.find((u) => u.id === replyingTo.userId) || null
: null : null
} }
messageInputRef={messageInputRef}
/> />
</div> </div>
)} )}