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 */}
<div className="border-t border-sidebar px-2 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="interactive-hover"
className="justify-start interactive-hover flex-grow-1"
onClick={openCreateChannel}
>
<Plus size={16} className="mr-1" />
@@ -87,7 +87,7 @@ const ChannelSidebar: React.FC = () => {
<Button
variant="ghost"
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}
>
<Users size={16} />

View File

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

View File

@@ -16,7 +16,7 @@ import { useState } from "react";
interface MessageUser {
id: string;
username?: string;
nickName?: string | null;
nickname?: string | null;
picture?: string | null;
}
@@ -64,8 +64,8 @@ export const MessageComponent: React.FC<MessageProps> = ({
const { mode } = useTheme();
// Get username with fallback
const username = user.username || user.userName || "Unknown User";
const displayName = user.nickname || user.nickName || username;
const username = user.username || user.username || "Unknown User";
const displayName = user.nickname || user.nickname || username;
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="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">
{replyToUser.nickname ||
replyToUser.nickName ||
replyToUser.username ||
replyToUser.userName}
{replyToUser.nickname || replyToUser.username}
</span>
<span className="truncate max-w-xs opacity-75">
{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 { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect } from "react"; // Keep useRef
import { useSendMessage } from "@/hooks/useMessages";
import { Button } from "@/components/ui/button";
interface MessageUser {
id: string;
username?: string;
nickName?: string | null;
nickname?: string | null;
picture?: string | null;
}
interface MessageInputProps {
@@ -15,7 +17,6 @@ interface MessageInputProps {
replyingTo?: Message | null;
onCancelReply?: () => void;
replyingToUser: MessageUser | null;
messageInputRef: React.RefObject<HTMLInputElement>;
}
export const MessageInput: React.FC<MessageInputProps> = ({
@@ -24,22 +25,26 @@ export const MessageInput: React.FC<MessageInputProps> = ({
replyingTo,
onCancelReply,
replyingToUser,
messageInputRef,
}) => {
const [content, setContent] = useState("");
const textareaRef = messageInputRef;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages
const sendMessageMutation = useSendMessage();
// Auto-resize textarea
// Auto-resize textarea and focus when replying
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
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) => {
e.preventDefault();
@@ -73,7 +78,7 @@ export const MessageInput: React.FC<MessageInputProps> = ({
<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" />
<span className="font-medium text-concord-primary">
{replyingToUser.nickname || replyingToUser.userName}
{replyingToUser.nickname || replyingToUser.username}
</span>
</div>
<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 { 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 { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -15,7 +15,6 @@ import { MessageInput } from "@/components/message/MessageInput";
const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams();
const navigate = useNavigate();
const messageInputRef = useRef<HTMLInputElement>(null);
const {
data: instance,
@@ -105,13 +104,6 @@ const ChatPage: React.FC = () => {
[channelMessages],
);
// Focus the message input
useEffect(() => {
if (messageInputRef.current) {
messageInputRef.current.focus();
}
}, [handleReply]);
// Effect for scroll to top and load more
useEffect(() => {
const scrollAreaElement = scrollAreaRef.current;
@@ -332,7 +324,6 @@ const ChatPage: React.FC = () => {
? users?.find((u) => u.id === replyingTo.userId) || null
: null
}
messageInputRef={messageInputRef}
/>
</div>
)}