- Don't compact after date separator (show full header on new day) - Don't compact typed messages (result/error/plan keep their badges) - URL regex stops before trailing ),. so links don't grab punctuation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
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 (
|
|
<div className="h-full flex flex-col items-center justify-center bg-background text-foreground px-6">
|
|
<span className="text-8xl mb-6">🐒</span>
|
|
<h1 className="font-sans text-3xl md:text-5xl font-bold uppercase tracking-wider mb-4">
|
|
Ape Colony
|
|
</h1>
|
|
<p className="font-mono text-sm text-muted-foreground mb-8 text-center max-w-md">
|
|
this is not a place for humans without names.
|
|
<br />
|
|
identify yourself, ape.
|
|
</p>
|
|
<div className="font-mono text-xs text-muted-foreground/50 border-2 border-border px-4 py-3 max-w-sm">
|
|
<div className="text-primary font-bold mb-1">// add ?user= to the URL</div>
|
|
<div>apes.unslope.com<span className="text-primary">?user=benji</span></div>
|
|
<div>apes.unslope.com<span className="text-primary">?user=neeraj</span></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <GatePage />;
|
|
}
|
|
const [channels, setChannels] = useState<Channel[]>([]);
|
|
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
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<HTMLDivElement>(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 = (
|
|
<ChannelSidebar
|
|
channels={sortedChannels}
|
|
activeId={activeChannelId}
|
|
onSelect={(id) => {
|
|
markOpened(id);
|
|
setActiveChannelId(id);
|
|
setSheetOpen(false);
|
|
}}
|
|
onChannelCreated={loadChannels}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden">
|
|
<div className="hidden md:flex md:flex-col md:h-full">
|
|
{sidebar}
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
|
|
<div className="px-4 py-3 md:px-6 md:py-4 border-b-2 border-border flex items-center gap-3">
|
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
<SheetTrigger className="md:hidden p-1 h-8 w-8 text-muted-foreground hover:text-primary font-mono font-bold text-lg">
|
|
=
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="p-0 w-56 bg-sidebar border-r-2 border-border">
|
|
{sidebar}
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{activeChannel ? (
|
|
<>
|
|
<span className="text-primary font-bold font-mono">#</span>
|
|
<span className="font-sans font-bold text-sm uppercase tracking-wide">{activeChannel.name}</span>
|
|
{activeChannel.description && (
|
|
<span className="text-xs font-mono text-muted-foreground ml-2 hidden md:inline">
|
|
{activeChannel.description}
|
|
</span>
|
|
)}
|
|
<span className="ml-auto text-[10px] font-mono text-muted-foreground tabular-nums">
|
|
{messages.length} msg
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground text-xs font-mono">no channel selected</span>
|
|
)}
|
|
</div>
|
|
|
|
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
|
|
{loading && messages.length === 0 ? (
|
|
<div className="p-5 space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex items-start gap-3">
|
|
<Skeleton className="h-6 w-6 rounded-none" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-3 w-24 rounded-none" />
|
|
<Skeleton className="h-3 w-full rounded-none" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : messages.length === 0 && activeChannelId ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-xs font-mono py-20">
|
|
no messages yet — start typing below
|
|
</div>
|
|
) : (
|
|
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 (
|
|
<div key={msg.id}>
|
|
{showDate && (
|
|
<div className="flex items-center gap-3 px-5 py-3">
|
|
<div className="flex-1 h-px bg-border" />
|
|
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-widest">
|
|
{new Date(msg.created_at).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
|
|
</span>
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
)}
|
|
<MessageItem
|
|
message={msg}
|
|
compact={compact}
|
|
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
|
|
currentUsername={getCurrentUsername()}
|
|
selected={selectedMessages.some((s) => 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
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</ScrollArea>
|
|
|
|
{/* Scroll-to-bottom button */}
|
|
{showScrollDown && (
|
|
<div className="flex justify-center -mt-6 mb-1 relative z-10">
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollToBottom(true)}
|
|
className="w-8 h-8 flex items-center justify-center border-2 border-border bg-card text-muted-foreground hover:text-primary hover:border-primary transition-colors shadow-lg text-sm"
|
|
>
|
|
↓
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{activeChannelId && (
|
|
<ComposeBox
|
|
key={activeChannelId}
|
|
channelId={activeChannelId}
|
|
replyTo={selectedMessages.length > 0 ? selectedMessages[0] : null}
|
|
selectedMessages={selectedMessages}
|
|
onClearReply={() => setSelectedMessages([])}
|
|
onMessageSent={() => {
|
|
setSelectedMessages([]);
|
|
loadMessages();
|
|
setTimeout(() => scrollToBottom(), 100);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|