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);
}}
/>
)}
);
}