'use client';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { MessageSquare, Clock, TrendingUp, Loader2 } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { PostThread } from '@/components/forum';
import { InlineCommentForm } from './InlineCommentForm';
import { getPosts, deletePost } from '@/actions/forum';
import { reportPost } from '@/actions/moderation';
import { useRealtimePosts } from '@/hooks/useRealtimePosts';
import type { ForumPost } from '@/types/forum';
/**
* Discussion scope for section-level discussions
*/
export interface DiscussionScope {
/** Full anchor ID (e.g., "bill:45-1:c-234:s2.1.a") */
anchorId: string;
/** Section reference extracted from anchor (e.g., "s2.1.a") */
sectionRef: string;
/** Human-readable label for the section */
label?: string;
}
interface BillDiscussionPanelProps {
/** Bill number (e.g., "C-234") */
billNumber: string;
/** Parliamentary session (e.g., "45-1") */
session: string;
/** Bill title for display */
billTitle?: string;
/** Current locale */
locale: string;
/** Currently selected section scope (null = whole bill) */
selectedSection?: DiscussionScope | null;
/** Callback when a comment is created/deleted (to refresh parent data) */
onCommentCreated?: () => void;
}
type SortOption = 'recent' | 'top';
/**
* Generate human-readable label for a section
*/
function getSectionLabel(sectionRef: string, locale: string): string {
// Parse section reference like "s2.1.a" or "part-1"
if (sectionRef.startsWith('part-')) {
const partNum = sectionRef.replace('part-', '');
return locale === 'fr' ? `Partie ${partNum}` : `Part ${partNum}`;
}
if (sectionRef.startsWith('s')) {
const ref = sectionRef.slice(1); // Remove 's' prefix
return locale === 'fr' ? `Section ${ref}` : `Section ${ref}`;
}
return sectionRef;
}
/**
* BillDiscussionPanel - Section-level discussions for bills
*
* Features:
* - Section-scoped discussions (linked to specific bill sections)
* - Fall back to whole-bill discussions when no section selected
* - Real-time updates via Supabase subscription
* - Sort by recent or top-voted
* - Create new comments with section context
*/
export const BillDiscussionPanel: React.FC<BillDiscussionPanelProps> = ({
billNumber,
session,
billTitle,
locale,
selectedSection,
onCommentCreated,
}) => {
const { user } = useAuth();
const [initialPosts, setInitialPosts] = useState<ForumPost[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>('top');
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [refreshKey, setRefreshKey] = useState(0); // Force refetch trigger
// Server handles section filtering via entity_metadata.section_ref JSONB query
const limit = 10;
// Determine the current discussion scope
const sectionRef = selectedSection?.sectionRef || null;
// Fetch posts based on current scope (server-side filtering and sorting)
useEffect(() => {
let cancelled = false;
const fetchPosts = async () => {
setIsLoading(true);
// Fetch only top-level posts (depth=0) - server handles filtering and sorting
// Reply counts are stored in reply_count column, no need to fetch actual replies
const params: Parameters<typeof getPosts>[0] = {
post_type: 'bill_comment',
bill_number: billNumber,
bill_session: session,
section_ref: sectionRef, // null = general discussion, string = specific section
sort_by: sortBy, // 'recent' or 'top' - server handles sorting
limit,
offset,
include_replies: false, // Only top-level posts, reply_count already has the count
};
// Add timeout to prevent hanging forever
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Fetch timed out after 10 seconds')), 10000);
});
const result = await Promise.race([getPosts(params), timeoutPromise]);
// Ignore results if this effect was cleaned up (stale request)
if (cancelled) return;
if (result.success && result.data) {
const postsData = result.data.data;
console.log('Fetched posts:', postsData.length, 'posts for section:', sectionRef, 'sort:', sortBy);
// Posts are already sorted by server, just set them directly
setInitialPosts(
offset === 0 ? postsData : [...initialPosts, ...postsData]
);
setHasMore(result.data.has_more);
} else {
console.error('Failed to fetch posts:', result.error);
}
setIsLoading(false);
};
fetchPosts().catch((err) => {
// Ignore errors from stale requests
if (cancelled) return;
console.error('Fetch posts error:', err);
setIsLoading(false);
});
// Cleanup: mark this request as stale when dependencies change
return () => {
cancelled = true;
};
}, [billNumber, session, sectionRef, sortBy, offset, refreshKey]);
// Reset pagination when scope changes
useEffect(() => {
setOffset(0);
}, [selectedSection]);
// Real-time updates
const posts = useRealtimePosts(initialPosts, {
billNumber,
billSession: session,
postType: 'bill_comment',
enabled: true,
});
// Client-side filter as safety net for real-time updates (server already filters)
// When sectionRef is null (General Discussion), only show posts WITHOUT a section_ref
// When sectionRef is set, only show posts FOR that specific section
const filteredPosts = useMemo(() => {
const filtered = sectionRef
? posts.filter((post) => post.entity_metadata?.section_ref === sectionRef)
: posts.filter((post) => !post.entity_metadata?.section_ref);
// Deduplicate posts by id (can happen when replies are also in the list)
const seen = new Set<string>();
return filtered.filter((post) => {
if (seen.has(post.id)) return false;
seen.add(post.id);
return true;
});
}, [posts, sectionRef]);
const handleSortChange = useCallback((newSort: SortOption) => {
setSortBy(newSort);
setOffset(0);
}, []);
const handleLoadMore = useCallback(() => {
if (!isLoading && hasMore) {
setOffset((prev) => prev + limit);
}
}, [isLoading, hasMore]);
// Refs for infinite scroll (defined here, useEffect below after sortedPosts)
const loadMoreRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handlePostCreated = useCallback(() => {
console.log('handlePostCreated called - triggering refetch');
// Refetch posts to show the new comment
setOffset(0);
setSortBy('recent');
setRefreshKey((k) => k + 1); // Force refetch
// Notify parent to refresh counts (e.g., General Discussion button, section counts)
onCommentCreated?.();
}, [onCommentCreated]);
const handleDelete = useCallback(async (postId: string) => {
const result = await deletePost(postId);
if (result.success) {
// Remove the deleted post from the list
setInitialPosts((prev) => prev.filter((p) => p.id !== postId));
// Notify parent to refresh counts
onCommentCreated?.();
} else {
alert(result.error || 'Failed to delete post');
}
}, [onCommentCreated]);
const handleReport = useCallback(async (postId: string) => {
if (!user) {
alert(locale === 'fr'
? 'Connectez-vous pour signaler'
: 'Please sign in to report posts');
return;
}
const result = await reportPost({ post_id: postId, reason: 'Reported by user' });
if (result.success) {
alert(locale === 'fr'
? 'Merci pour votre signalement.'
: 'Thank you for your report.');
} else {
// Show specific error message (includes "already reported" case)
alert(result.error || (locale === 'fr'
? 'Échec du signalement'
: 'Failed to report post'));
}
}, [user, locale]);
// Client-side sort as safety net (server already sorts, but re-sort after real-time updates)
const sortedPosts = useMemo(() => {
const sorted = [...filteredPosts];
if (sortBy === 'top') {
// Sort by net score (upvotes - downvotes), descending
sorted.sort((a, b) => {
const aScore = (a.upvotes_count ?? 0) - (a.downvotes_count ?? 0);
const bScore = (b.upvotes_count ?? 0) - (b.downvotes_count ?? 0);
if (bScore !== aScore) return bScore - aScore;
// Tie-breaker: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
} else {
// Sort by most recent first
sorted.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
return sorted;
}, [filteredPosts, sortBy]);
// Infinite scroll: observe when sentinel element comes into view
useEffect(() => {
const sentinel = loadMoreRef.current;
const scrollContainer = scrollContainerRef.current;
// Wait for sentinel to be rendered (it only renders when sortedPosts.length > 0)
if (!sentinel || !scrollContainer || !hasMore || isLoading) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore && !isLoading) {
handleLoadMore();
}
},
{
// Use the scroll container as the root for intersection
root: scrollContainer,
// Trigger when sentinel is 100px from entering the viewport
rootMargin: '100px',
threshold: 0,
}
);
observer.observe(sentinel);
// Check if sentinel is already in view after DOM settles (handles initial load case)
// Use double requestAnimationFrame to ensure DOM is fully rendered
let rafId: number;
rafId = requestAnimationFrame(() => {
rafId = requestAnimationFrame(() => {
if (sentinel && scrollContainer && hasMore && !isLoading) {
const containerRect = scrollContainer.getBoundingClientRect();
const sentinelRect = sentinel.getBoundingClientRect();
// Check if sentinel is within or near the container's visible area
if (sentinelRect.top < containerRect.bottom + 100) {
handleLoadMore();
}
}
});
});
return () => {
observer.disconnect();
cancelAnimationFrame(rafId);
};
// Include sortedPosts.length so effect re-runs after posts render and sentinel becomes available
}, [hasMore, isLoading, handleLoadMore, sortedPosts.length]);
return (
<div className="h-full flex flex-col">
{/* Header moved to sticky header in BillSplitView */}
{/* Sort controls */}
{sortedPosts.length > 0 && (
<div className="flex-shrink-0 px-2 py-2 flex items-center justify-end gap-2">
<button
onClick={() => handleSortChange('top')}
className={`
flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium
transition-colors
${sortBy === 'top'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
}
`}
>
<TrendingUp className="h-3.5 w-3.5" />
{locale === 'fr' ? 'Populaire' : 'Top'}
</button>
<button
onClick={() => handleSortChange('recent')}
className={`
flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium
transition-colors
${sortBy === 'recent'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
}
`}
>
<Clock className="h-3.5 w-3.5" />
{locale === 'fr' ? 'Récent' : 'Recent'}
</button>
</div>
)}
{/* Inline comment form at top */}
<div className="flex-shrink-0 px-2 py-3 border-b border-border-subtle">
<InlineCommentForm
billNumber={billNumber}
session={session}
sectionRef={selectedSection?.sectionRef || null}
billTitle={billTitle}
locale={locale}
onSuccess={handlePostCreated}
placeholder={
selectedSection
? (locale === 'fr'
? `Commentez ${getSectionLabel(selectedSection.sectionRef, locale)}...`
: `Comment on ${getSectionLabel(selectedSection.sectionRef, locale)}...`)
: (locale === 'fr'
? `Partagez votre avis sur ${billTitle || `le projet de loi ${billNumber}`}...`
: `Share your thoughts on ${billTitle || `Bill ${billNumber}`}...`)
}
/>
</div>
{/* Posts list */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-4 py-2">
{isLoading && offset === 0 ? (
// Loading skeleton
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 animate-pulse"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
</div>
))}
</div>
) : sortedPosts.length === 0 ? (
<div className="text-center py-8">
<MessageSquare className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
<h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
{locale === 'fr' ? 'Aucune discussion' : 'No discussions yet'}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">
{locale === 'fr'
? 'Soyez le premier à partager votre opinion!'
: 'Be the first to share your thoughts!'}
</p>
</div>
) : (
<>
<div className="space-y-4">
{sortedPosts.map((post) => (
<PostThread
key={post.id}
post={post}
onDelete={handleDelete}
onReport={handleReport}
onReplySuccess={() => {
setOffset(0);
setRefreshKey((k) => k + 1);
// Notify parent to refresh counts
onCommentCreated?.();
}}
maxDepth={5}
showCollapseButton={true}
showTitle={false}
defaultCollapsed={true}
/>
))}
</div>
{/* Infinite scroll sentinel - triggers load when scrolled into view */}
<div ref={loadMoreRef} className="h-4 mt-4">
{isLoading && offset > 0 && (
<div className="flex items-center justify-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{locale === 'fr' ? 'Chargement...' : 'Loading...'}</span>
</div>
)}
</div>
</>
)}
</div>
</div>
);
};
export default BillDiscussionPanel;