sipher/src/components/ui/layout/channel-list.tsx
Nixyan e7dd6c961d feat: enhance user status management and introduce nests functionality
- Updated user status handling to include optional user-set status, improving user experience during reconnections.
- Added new queries and mutations for managing nests, including fetching non-offline user IDs and forcing users offline.
- Introduced new database schema for nests, roles, and channels, enhancing the application's organizational structure.
- Updated dependencies in package.json and bun.lock for improved stability and compatibility.
- Refactored related components and API to support the new nests functionality.
2026-02-20 10:01:07 -03:00

372 lines
13 KiB
TypeScript

"use client"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { clearUnread, db } from "@/lib/db"
import { QuestionMarkIcon } from "@phosphor-icons/react"
import { formatDistanceToNow } from "date-fns"
import { useLiveQuery } from "dexie-react-hooks"
import { Globe2Icon, GlobeIcon, HomeIcon, MessageSquarePlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import UserCard from "../user/user-card"
// Mock channels for testing scroll behavior - set to true to enable
const ENABLE_MOCK_CHANNELS = true
function createMockChannel(id: string, name: string, message: string, hoursAgo: number): SiPher.Channel {
return {
id: `mock-${id}`,
name,
type: "DM" as SiPher.Channel["type"],
participants: ["current-user", `user-${id}`],
isOpen: true,
metadata: {},
times: {
createdAt: Date.now() - 1000 * 60 * 60 * 24 * 30,
updatedAt: Date.now() - 1000 * 60 * 60 * hoursAgo,
lastMessage: { content: message } as unknown as SiPher.Channel["times"]["lastMessage"],
lastMessageAt: Date.now() - 1000 * 60 * 60 * hoursAgo
}
}
}
// const mockChannels: SiPher.Channel[] = ENABLE_MOCK_CHANNELS ? [
// createMockChannel("1", "Alice Johnson", "Hey, are you coming to the meeting?", 0.08),
// createMockChannel("2", "Bob Smith", "The project looks great!", 0.5),
// createMockChannel("3", "Charlie Brown", "Can you review my PR?", 1),
// createMockChannel("4", "Diana Prince", "Thanks for the help!", 2),
// createMockChannel("5", "Edward Norton", "Let's catch up soon", 5),
// createMockChannel("6", "Fiona Green", "Did you see the news?", 12),
// createMockChannel("7", "George Wilson", "Meeting at 3pm", 24),
// createMockChannel("8", "Hannah Baker", "Sounds good to me!", 48),
// createMockChannel("9", "Ivan Petrov", "I'll send over the files", 72),
// createMockChannel("10", "Julia Roberts", "Great work on that!", 96),
// createMockChannel("11", "Kevin Hart", "LOL that's hilarious", 120),
// createMockChannel("12", "Laura Palmer", "See you tomorrow", 144),
// ] : []
export interface ChannelListProps {
currentChannel: SiPher.Channel | null
openDmChannels: SiPher.Channel[]
page: SiPher.PageTypes
onPageChange: (page: SiPher.PageTypes) => void
emptyMessage?: string
dmChannel?: {
id: string
participantDetails: {
id: string
name: string
username: string
displayUsername: string
image: string
status: "online" | "busy" | "offline" | "away"
isCurrentUser: boolean
}[]
}
onChannelSelect?: () => void
isMobile?: boolean
}
export function ChannelList({
currentChannel,
openDmChannels,
page,
onPageChange,
emptyMessage = "No messages yet",
dmChannel,
onChannelSelect,
isMobile,
}: ChannelListProps) {
const router = useRouter()
const unreadCount = useLiveQuery(
() => db.unreadCounts.toArray(),
[]
)
// Combine real channels with mock channels for testing, sorted by most recent activity
const allDmChannels = useMemo(() => {
return [...openDmChannels].sort((a, b) => {
const aTime = a.times?.lastMessageAt ?? a.times?.updatedAt ?? 0
const bTime = b.times?.lastMessageAt ?? b.times?.updatedAt ?? 0
return bTime - aTime // Descending order (most recent first)
})
}, [openDmChannels])
const handleNavigation = (path: string) => {
router.push(path)
onChannelSelect?.()
}
return (
<div className={`flex flex-col shrink-0 border-border/40 ${isMobile ? 'w-full h-full bg-transparent' : 'max-w-72 min-w-72 h-full border-r bg-linear-to-b from-background to-muted/20'}`}>
{/* Channel List Header - Navigation Items (Desktop only) */}
{!isMobile && (
<>
<div className="flex flex-col p-2 gap-1">
<Button
variant="ghost"
className={`w-full justify-start gap-3 h-11 px-3 rounded-lg transition-all ${page === "friends"
? "bg-primary/10 text-primary hover:bg-primary/15 ring-1 ring-primary/20"
: "hover:bg-accent/60"
}`}
onClick={() => {
onPageChange("friends")
handleNavigation("/channels/me/friends")
}}
>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "friends"
? "bg-primary/20"
: "bg-muted/50"
}`}>
<UsersIcon className="size-4" />
</div>
<span className="text-sm font-semibold">Friends</span>
</Button>
<Button
variant="ghost"
className={`w-full justify-start gap-3 h-11 px-3 rounded-lg transition-all ${page === "support"
? "bg-primary/10 text-primary hover:bg-primary/15 ring-1 ring-primary/20"
: "hover:bg-accent/60"
}`}
onClick={() => {
onPageChange("support")
onChannelSelect?.()
}}
>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "support"
? "bg-primary/20"
: "bg-muted/50"
}`}>
<SettingsIcon className="size-4" />
</div>
<span className="text-sm font-semibold">Settings</span>
</Button>
</div>
{/* Divider with label */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="h-px flex-1 bg-linear-to-r from-border/60 to-transparent" />
</div>
</>
)}
{/* Mobile Navigation Buttons */}
{isMobile && (
<div className="flex gap-2 px-2 py-2">
<Button
variant={page === "friends" ? "default" : "outline"}
size="sm"
className="flex-1 h-9 text-xs font-semibold"
onClick={() => {
onPageChange("friends")
handleNavigation("/")
}}
>
<UsersIcon className="size-3.5 mr-1.5" />
Friends
</Button>
<Button
variant={page === "support" ? "default" : "outline"}
size="sm"
className="flex-1 h-9 text-xs font-semibold"
onClick={() => {
onPageChange("support")
onChannelSelect?.()
}}
>
<SettingsIcon className="size-3.5 mr-1.5" />
Settings
</Button>
</div>
)}
{/* Channel List */}
<div className={`flex flex-col flex-1 min-h-0 ${isMobile ? 'px-2' : 'px-2'}`}>
<div className="flex flex-col w-full gap-2">
<div className="flex items-center justify-between px-1 py-2 select-none">
<span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}>
Global Nests
</span>
</div>
{/* Nest Type Selector */}
<div className={`flex ${isMobile ? 'flex-col gap-1' : 'flex-row gap-0.5'} p-1 bg-muted/40 rounded-lg`}>
{[
{ id: 'global', icon: GlobeIcon, label: 'Global', description: 'Worldwide nests' },
{ id: 'continental', icon: Globe2Icon, label: 'Continent', description: 'Nests by continent' },
{ id: 'country', icon: HomeIcon, label: 'Country', description: 'Your country nests' },
].map((nest) => {
const isActive = nest.id === 'global' // TODO: Replace with actual state
const Icon = nest.icon
return (
<Button
key={nest.id}
variant="ghost"
size="sm"
className={`
${isMobile
? 'w-full justify-start gap-3 h-11 px-3'
: 'flex-1 gap-1.5 h-7 px-2'
}
rounded-md transition-all duration-150
${isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-transparent'
}
`}
title={nest.description}
onClick={() => {
// TODO: Handle nest type selection
onChannelSelect?.()
}}
>
{isMobile ? (
<>
<div className={`
flex items-center justify-center w-7 h-7 rounded-md transition-colors
${isActive ? 'bg-primary/15' : 'bg-muted/50'}
`}>
<Icon className="size-4" />
</div>
<span className="text-sm font-medium">{nest.label}</span>
</>
) : (
<>
<Icon className="size-3.5" />
<span className="text-[11px] font-medium">{nest.label}</span>
</>
)}
</Button>
)
})}
</div>
</div>
{(page === "friends" || !currentChannel) && (
<div className="flex flex-col w-full flex-1 min-h-0 overflow-hidden mb-16">
{/* DM Header */}
<div className="flex items-center justify-between px-1 py-2 select-none shrink-0">
<span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}>
Direct Messages
</span>
<Button
variant="ghost"
size="icon-sm"
className="size-6 hover:bg-accent rounded-md"
title="New Message"
>
<MessageSquarePlusIcon className="size-3.5" />
</Button>
</div>
{allDmChannels.length > 0 && (
<ScrollArea className="flex-1 -mx-2 h-full">
<div className="flex flex-col gap-0.5 px-2 pb-2">
{allDmChannels.map((channel) => {
const isActive = dmChannel?.id === channel.id
const lastMessage = channel.times?.lastMessage
const lastMessageTime = channel.times?.lastMessageAt
const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
if (!channel.isOpen) return null;
return (
<div
key={channel.id}
className={`flex flex-row items-center gap-3 px-2 py-2.5 rounded-lg transition-all cursor-pointer group ${isActive
? "bg-accent/80 shadow-sm ring-1 ring-accent"
: "hover:bg-accent/40 active:bg-accent/60"
}`}
onClick={() => {
clearUnread(channel.id)
console.log("Cleared unread count for channel", channel.id)
handleNavigation(`/channels/me/${channel.id}`)
}}
>
<div className="relative shrink-0">
<UserCard
userName={channel.name}
image={channel.metadata?.icon ?? undefined}
status={"none"}
/>
{channelUnreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-linear-to-br from-red-500 to-red-600 text-[10px] font-bold text-white shadow-md ring-2 ring-background">
{channelUnreadCount > 99 ? '99+' : channelUnreadCount}
</span>
)}
</div>
{/* Channel Info */}
<div className="flex flex-col justify-center flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<span className={`text-sm truncate ${isActive ? 'font-bold' : 'font-semibold'} text-foreground`}>
{channel.name}
</span>
{lastMessageTime && (
<span className="text-[10px] text-muted-foreground/60 shrink-0 font-medium">
{formatDistanceToNow(lastMessageTime, { addSuffix: false })}
</span>
)}
</div>
{lastMessage && (
<span className="text-xs text-muted-foreground/70 truncate mt-0.5">
{lastMessage.content}
</span>
)}
</div>
{/* Close button - always visible on mobile, hover-visible on desktop */}
<Button
variant="ghost"
size="icon"
className={`size-7 p-0 shrink-0 hover:bg-destructive/10 hover:text-destructive rounded-md transition-all ${isMobile ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'}`}
onClick={(e) => {
e.stopPropagation()
const isCurrentlyViewing = isActive
db.channels.where("id").equals(channel.id).modify((channel) => {
channel.isOpen = false;
});
// Navigate away if we're closing the currently viewed channel
if (isCurrentlyViewing) {
console.log("Navigating away from channel")
handleNavigation("/")
}
}}
title="Close DM"
>
<XIcon className="size-3.5" />
</Button>
</div>
)
})}
</div>
</ScrollArea>
)}
{allDmChannels.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted/50 mb-3">
<QuestionMarkIcon size={20} className="text-muted-foreground/50" />
</div>
<span className="text-xs font-medium text-muted-foreground/70 leading-relaxed">
{emptyMessage}
</span>
</div>
)}
</div>
)}
{page !== "friends" && currentChannel && (
<div className="flex items-center justify-center py-8">
<span className="text-sm font-medium text-muted-foreground">No channels</span>
</div>
)}
</div>
</div>
)
}