import { useCallback, useEffect, useRef, useState } from "react"; import type { Channel } from "@/types/Channel"; import type { Message } from "@/types/Message"; import { getChannels, getMessages, getCurrentUsername, deleteMessage } from "@/api"; import { ChannelSidebar } from "@/components/ChannelSidebar"; import { MessageItem } from "@/components/MessageItem"; import { ComposeBox } from "@/components/ComposeBox"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { useChannelSocket } from "@/hooks/useChannelSocket"; function GatePage() { return (
🐒

Ape Colony

this is not a place for humans without names.
identify yourself, ape.

// add ?user= to the URL
apes.unslope.com?user=benji
apes.unslope.com?user=neeraj
); } export default function App() { // Gate: require ?user= param in URL (not just localStorage) const urlUser = new URL(window.location.href).searchParams.get("user"); const storedUser = localStorage.getItem("colony_user"); if (!urlUser && !storedUser) { return ; } const [channels, setChannels] = useState([]); const [activeChannelId, setActiveChannelId] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [selectedMessages, setSelectedMessages] = useState<{ id: string; username: string; content: string }[]>([]); const [sheetOpen, setSheetOpen] = useState(false); const [showScrollDown, setShowScrollDown] = useState(false); const scrollRef = useRef(null); const prevMsgCountRef = useRef(0); const activeChannelRef = useRef(activeChannelId); activeChannelRef.current = activeChannelId; const loadChannels = useCallback(async () => { const chs = await getChannels(); setChannels(chs); setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null)); }, []); const loadMessages = useCallback(async () => { const channelId = activeChannelRef.current; if (!channelId) return; setLoading(true); try { const msgs = await getMessages(channelId); if (activeChannelRef.current === channelId) { setMessages(msgs); } } catch { // Silently ignore fetch errors } finally { setLoading(false); } }, []); // WebSocket: append new messages in real-time const handleWsMessage = useCallback((msg: Message) => { setMessages((prev) => { if (prev.some((m) => m.id === msg.id)) return prev; return [...prev, msg]; }); }, []); // On WS reconnect, refetch full history to catch missed messages const handleWsReconnect = useCallback(() => { loadMessages(); }, [loadMessages]); useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect); useEffect(() => { loadChannels(); }, [loadChannels]); useEffect(() => { setMessages([]); setSelectedMessages([]); prevMsgCountRef.current = 0; loadMessages(); }, [activeChannelId, loadMessages]); function getViewport() { const el = scrollRef.current as unknown as HTMLElement | null; return el?.querySelector('[data-slot="scroll-area-viewport"]') as HTMLElement | null ?? el; } function scrollToBottom(smooth = false) { const vp = getViewport(); if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" }); } // Auto-scroll only on new messages useEffect(() => { if (messages.length > prevMsgCountRef.current) { scrollToBottom(); } prevMsgCountRef.current = messages.length; }, [messages]); // Track scroll position for scroll-down button useEffect(() => { const vp = getViewport(); if (!vp) return; function onScroll() { const v = vp!; setShowScrollDown(v.scrollHeight - v.scrollTop - v.clientHeight > 150); } vp.addEventListener("scroll", onScroll, { passive: true }); return () => vp.removeEventListener("scroll", onScroll); }); const messagesById = new Map(messages.map((m) => [m.id, m])); const activeChannel = channels.find((c) => c.id === activeChannelId); // Sort channels by last opened (stored in localStorage) function getLastOpened(channelId: string): number { return Number(localStorage.getItem(`colony_last_opened_${channelId}`)) || 0; } function markOpened(channelId: string) { localStorage.setItem(`colony_last_opened_${channelId}`, String(Date.now())); } const sortedChannels = [...channels].sort((a, b) => getLastOpened(b.id) - getLastOpened(a.id)); const sidebar = ( { markOpened(id); setActiveChannelId(id); setSheetOpen(false); }} onChannelCreated={loadChannels} /> ); return (
{sidebar}
= {sidebar} {activeChannel ? ( <> # {activeChannel.name} {activeChannel.description && ( {activeChannel.description} )} {messages.length} msg ) : ( no channel selected )}
{loading && messages.length === 0 ? (
{[1, 2, 3].map((i) => (
))}
) : messages.length === 0 && activeChannelId ? (
no messages yet — start typing below
) : ( messages.map((msg, i) => { const prev = i > 0 ? messages[i - 1] : null; const sameSender = prev && prev.user.username === msg.user.username; const withinWindow = prev && (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime()) < 5 * 60 * 1000; const prevDate = prev ? new Date(prev.created_at).toDateString() : null; const thisDate = new Date(msg.created_at).toDateString(); const showDate = prevDate !== thisDate; // Don't compact: after date break, typed messages (non-text), or replies const isTyped = msg.type !== "text"; const compact = !!(sameSender && withinWindow && !msg.reply_to && !showDate && !isTyped); return (
{showDate && (
{new Date(msg.created_at).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
)} s.id === msg.id)} onSelect={(id) => { setSelectedMessages((prevSel) => { const exists = prevSel.find((s) => s.id === id); if (exists) return prevSel.filter((s) => s.id !== id); const target = messagesById.get(id); if (!target) return prevSel; return [...prevSel, { id, username: target.user.display_name, content: target.content }]; }); }} onDelete={async (chId, msgId) => { try { await deleteMessage(chId, msgId); loadMessages(); } catch { // ignore } }} />
); }) )} {/* Scroll-to-bottom button */} {showScrollDown && (
)} {activeChannelId && ( 0 ? selectedMessages[0] : null} selectedMessages={selectedMessages} onClearReply={() => setSelectedMessages([])} onMessageSent={() => { setSelectedMessages([]); loadMessages(); setTimeout(() => scrollToBottom(), 100); }} /> )}
); }