/**
* InlineCommentForm - Compact inline form for creating top-level bill comments
*
* Features:
* - Auto-expanding textarea (starts as single line)
* - Character count indicator (1-10,000 chars)
* - Auth-gated (requires sign-in)
* - Section context support (via sectionRef)
* - @mention autocomplete for MPs, bills, committees, etc.
* - Conversation draft preview (from chatbot share)
*/
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { X, GitFork, MessageSquare } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { createPost } from '@/actions/forum';
import { formatBillMentionWithSession, getMentionAtCursor } from '@/lib/mentions/mentionParser';
import { MentionAutocomplete, type MentionSuggestion } from '@/components/mentions/MentionAutocomplete';
// Type for conversation draft from chatbot
interface ConversationDraft {
userQuestion: string;
aiResponse: string;
conversationId: string;
billContext?: {
number: string;
session: string;
title?: string;
};
timestamp: number;
}
interface InlineCommentFormProps {
billNumber: string;
session: string;
sectionRef: string | null; // null = general discussion
billTitle?: string;
locale: string;
onSuccess?: () => void;
onCancel?: () => void;
placeholder?: string;
autoFocus?: boolean;
}
const MAX_LENGTH = 10000;
/**
* Get the actual user content (excluding leading @bill mention)
*/
function getContentAfterMention(text: string): string {
const trimmed = text.trimStart();
// Check if starts with @bill: mention
if (trimmed.startsWith('@bill:')) {
// Find end of the mention (next whitespace)
const mentionEndMatch = trimmed.match(/^@bill:[^\s]+\s*/);
if (mentionEndMatch) {
return trimmed.slice(mentionEndMatch[0].length).trim();
}
}
return trimmed;
}
/**
* Extract section reference from a bill mention in content
* Handles format: @bill:c-12:s1 (billNumber:section)
*/
function extractSectionFromMention(text: string): string | null {
const trimmed = text.trimStart();
if (!trimmed.startsWith('@bill:')) return null;
// Match @bill:billNumber:section pattern (no session prefix)
// e.g., @bill:c-12:s3 -> extracts "s3"
const mentionMatch = trimmed.match(/^@bill:([^:\s]+):([^\s]+)/i);
if (mentionMatch && mentionMatch[2]) {
return mentionMatch[2];
}
return null;
}
export function InlineCommentForm({
billNumber,
session,
sectionRef,
billTitle,
locale,
onSuccess,
onCancel,
placeholder,
autoFocus = false,
}: InlineCommentFormProps) {
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(autoFocus);
const [hasInitializedMention, setHasInitializedMention] = useState(false);
const [conversationDraft, setConversationDraft] = useState<ConversationDraft | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previousSectionRef = useRef<string | null>(sectionRef);
const formRef = useRef<HTMLFormElement>(null);
// Autocomplete state
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteQuery, setAutocompleteQuery] = useState('');
const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 });
const [mentionStartIndex, setMentionStartIndex] = useState<number | null>(null);
const autocompleteDebounceRef = useRef<NodeJS.Timeout | null>(null);
// Auto-prepend bill mention on mount
useEffect(() => {
if (!hasInitializedMention) {
const mention = formatBillMentionWithSession(session, billNumber, sectionRef);
setContent(`${mention} `);
setHasInitializedMention(true);
}
}, [session, billNumber, sectionRef, hasInitializedMention]);
// Update mention when section changes (user navigated to different section)
useEffect(() => {
if (hasInitializedMention && previousSectionRef.current !== sectionRef) {
const newMention = formatBillMentionWithSession(session, billNumber, sectionRef);
const currentContent = content.trimStart();
// Check if content starts with a bill mention, replace it
if (currentContent.startsWith('@bill:')) {
const mentionEndMatch = currentContent.match(/^@bill:[^\s]+\s*/);
if (mentionEndMatch) {
setContent(newMention + ' ' + currentContent.slice(mentionEndMatch[0].length));
}
} else {
// Prepend the new mention
setContent(newMention + ' ' + currentContent);
}
previousSectionRef.current = sectionRef;
}
}, [sectionRef, hasInitializedMention, session, billNumber, content]);
// Auto-focus if requested
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [content]);
// Cleanup debounce timeout on unmount
useEffect(() => {
return () => {
if (autocompleteDebounceRef.current) {
clearTimeout(autocompleteDebounceRef.current);
}
};
}, []);
// Check for conversation draft from chatbot on mount and via custom event
useEffect(() => {
const loadDraft = () => {
try {
const draftJson = sessionStorage.getItem('share_conversation_draft');
if (draftJson) {
const draft = JSON.parse(draftJson) as ConversationDraft;
// Only load if draft is recent (within 5 minutes) and matches this bill
const isRecent = Date.now() - draft.timestamp < 5 * 60 * 1000;
const matchesBill = draft.billContext?.number === billNumber &&
draft.billContext?.session === session;
if (isRecent && matchesBill) {
setConversationDraft(draft);
setIsFocused(true);
// Clear from storage so it doesn't show again
sessionStorage.removeItem('share_conversation_draft');
// Scroll to this form
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
} catch (e) {
console.error('Failed to load conversation draft:', e);
}
};
// Load on mount
loadDraft();
// Listen for custom event (when already on the bill page)
const handleDraftEvent = (e: CustomEvent<ConversationDraft>) => {
setConversationDraft(e.detail);
setIsFocused(true);
sessionStorage.removeItem('share_conversation_draft');
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
window.addEventListener('openDiscussionWithDraft', handleDraftEvent as EventListener);
return () => {
window.removeEventListener('openDiscussionWithDraft', handleDraftEvent as EventListener);
};
}, [billNumber, session]);
// Handle content change with mention detection
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
const cursorPosition = e.target.selectionStart || 0;
setContent(newContent);
// Clear any pending debounce
if (autocompleteDebounceRef.current) {
clearTimeout(autocompleteDebounceRef.current);
autocompleteDebounceRef.current = null;
}
// Check if user is typing a mention
const mentionAtCursor = getMentionAtCursor(newContent, cursorPosition);
if (mentionAtCursor && mentionAtCursor.mention.length >= 2) {
// Extract query (text after @ or @type:)
const mention = mentionAtCursor.mention;
let query = mention.slice(1); // Remove @
// If it's @type:query format, extract just the query part after the type
// e.g., "@bill:c-12" -> "c-12", "@mp:pierre" -> "pierre"
const colonIndex = query.indexOf(':');
if (colonIndex !== -1) {
query = query.slice(colonIndex + 1);
}
// Strip session prefix if present (e.g., "45-1/c-12" -> "c-12")
// This handles the auto-prepended bill mention format
const sessionPrefixMatch = query.match(/^\d+-\d+\//);
if (sessionPrefixMatch) {
query = query.slice(sessionPrefixMatch[0].length);
}
// Skip if query is too short (need at least 2 chars to search)
if (query.length < 2) {
setShowAutocomplete(false);
setAutocompleteQuery('');
setMentionStartIndex(null);
return;
}
// Store position data immediately (doesn't cause flickering)
setMentionStartIndex(mentionAtCursor.startIndex);
// Calculate position for autocomplete popup
if (textareaRef.current && formRef.current) {
const textarea = textareaRef.current;
const formRect = formRef.current.getBoundingClientRect();
const textareaRect = textarea.getBoundingClientRect();
// Position below the textarea
setAutocompletePosition({
top: textareaRect.bottom - formRect.top + 4,
left: 0,
});
}
// Debounce the autocomplete query and visibility to prevent flickering
autocompleteDebounceRef.current = setTimeout(() => {
setAutocompleteQuery(query);
setShowAutocomplete(true);
}, 200); // 200ms debounce
} else {
// Hide autocomplete immediately when not typing a mention
setShowAutocomplete(false);
setAutocompleteQuery('');
setMentionStartIndex(null);
}
}, []);
// Handle autocomplete selection
const handleMentionSelect = useCallback((suggestion: MentionSuggestion) => {
if (mentionStartIndex === null) return;
// Replace the partial mention with the full mention string
const beforeMention = content.slice(0, mentionStartIndex);
const cursorPos = textareaRef.current?.selectionStart || content.length;
const afterMention = content.slice(cursorPos);
const newContent = beforeMention + suggestion.mentionString + ' ' + afterMention.trimStart();
setContent(newContent);
// Reset autocomplete state
setShowAutocomplete(false);
setAutocompleteQuery('');
setMentionStartIndex(null);
// Focus back on textarea
textareaRef.current?.focus();
}, [content, mentionStartIndex]);
// Handle autocomplete cancel
const handleAutocompleteCancel = useCallback(() => {
setShowAutocomplete(false);
setAutocompleteQuery('');
setMentionStartIndex(null);
}, []);
// Handle dismissing the conversation draft
const handleDismissDraft = useCallback(() => {
setConversationDraft(null);
}, []);
// Truncate text for preview
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trim() + '...';
};
// Format draft content for the post
const formatDraftForPost = (draft: ConversationDraft): string => {
return `> **Question:** ${draft.userQuestion}
>
> **Gordie:** ${draft.aiResponse}
*Shared from CanadaGPT conversation*
---
`;
};
// Redirect to sign-in if not authenticated
if (!user) {
return (
<div className="py-3 px-4 bg-bg-elevated border border-border-subtle rounded-lg text-center">
<p className="text-sm text-text-secondary mb-2">
{locale === 'fr'
? 'Connectez-vous pour participer à la discussion'
: 'Sign in to join the discussion'}
</p>
<a
href="/auth/signin"
className="inline-flex items-center gap-2 px-4 py-2 bg-accent-red hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors"
>
{locale === 'fr' ? 'Se connecter' : 'Sign In'}
</a>
</div>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const actualContent = getContentAfterMention(content);
if (actualContent.length < 1) {
setError(
locale === 'fr'
? 'Veuillez ajouter un commentaire après la mention'
: 'Please add a comment after the mention'
);
return;
}
if (content.length > MAX_LENGTH) {
setError(
locale === 'fr'
? `Le commentaire ne doit pas dépasser ${MAX_LENGTH} caractères`
: `Comment must not exceed ${MAX_LENGTH} characters`
);
return;
}
// Validate required fields
if (!billNumber || !session) {
setError(
locale === 'fr'
? 'Informations du projet de loi manquantes'
: 'Bill information is missing'
);
console.error('Missing bill info:', { billNumber, session });
return;
}
setIsSubmitting(true);
setError(null);
try {
// Extract section from the mention if user typed one (e.g., @bill:45-1/c-12:s1)
const extractedSection = extractSectionFromMention(content);
const effectiveSection = extractedSection || sectionRef;
// Generate auto-title for bill comments
const sectionLabel = effectiveSection || (locale === 'fr' ? 'Discussion générale' : 'General Discussion');
const autoTitle = conversationDraft
? `AI Analysis: ${conversationDraft.billContext?.title || `Bill ${billNumber}`}`
: `Comment on Bill ${session}/${billNumber} - ${sectionLabel}`;
// Content is just the user's text (no conversation dump)
// The conversation will be rendered from entity_metadata by SharedConversationEmbed
const postData = {
post_type: 'bill_comment' as const,
bill_number: billNumber,
bill_session: session,
title: autoTitle,
content, // User's own comment only
entity_metadata: {
...(effectiveSection ? { section_ref: effectiveSection } : {}),
...(conversationDraft ? {
shared_from_chat: true,
conversation_id: conversationDraft.conversationId,
conversation_snapshot: {
messages: [
{ role: 'user', content: conversationDraft.userQuestion },
{ role: 'assistant', content: conversationDraft.aiResponse },
],
billContext: conversationDraft.billContext,
},
} : {}),
},
};
console.log('Creating post with data:', postData);
// Add timeout to prevent hanging forever
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Request timed out after 15 seconds')), 15000);
});
const result = await Promise.race([createPost(postData), timeoutPromise]);
console.log('Post creation result:', result);
if (result.success) {
// Reset to fresh mention for next comment
const freshMention = formatBillMentionWithSession(session, billNumber, sectionRef);
setContent(`${freshMention} `);
setIsFocused(false);
setConversationDraft(null); // Clear draft on success
onSuccess?.();
} else {
console.error('Failed to create post:', result.error);
setError(result.error || (locale === 'fr' ? 'Échec de la création du commentaire' : 'Failed to create comment'));
}
} catch (err) {
console.error('Error creating post:', err);
setError(
locale === 'fr'
? `Une erreur est survenue: ${err instanceof Error ? err.message : 'Unknown error'}`
: `An error occurred: ${err instanceof Error ? err.message : 'Unknown error'}`
);
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
// Reset to fresh mention
const freshMention = formatBillMentionWithSession(session, billNumber, sectionRef);
setContent(`${freshMention} `);
setError(null);
setIsFocused(false);
onCancel?.();
};
const charCount = content.length;
const actualContent = getContentAfterMention(content);
const isOverLimit = charCount > MAX_LENGTH;
const isValid = actualContent.length >= 1 && charCount <= MAX_LENGTH;
return (
<form ref={formRef} onSubmit={handleSubmit} className="space-y-2 relative">
{/* Conversation Draft Preview Card */}
{conversationDraft && (
<div className="mb-3 bg-gray-800 border border-gray-600 rounded-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-3 bg-gray-900/50 border-b border-gray-700">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-accent-red" />
<span className="text-sm font-medium text-white">
{locale === 'fr' ? 'Conversation partagée' : 'Shared Conversation'}
</span>
</div>
<button
type="button"
onClick={handleDismissDraft}
className="p-1 hover:bg-gray-700 rounded transition-colors"
title={locale === 'fr' ? 'Supprimer' : 'Remove'}
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* Content Preview */}
<div className="p-3 space-y-2">
<div className="pl-3 border-l-2 border-accent-red/50 space-y-2">
<div>
<span className="text-xs font-medium text-gray-400">
{locale === 'fr' ? 'Question:' : 'Question:'}
</span>
<p className="text-sm text-gray-300 mt-0.5">
{truncateText(conversationDraft.userQuestion, 150)}
</p>
</div>
<div>
<span className="text-xs font-medium text-gray-400">Gordie:</span>
<p className="text-sm text-gray-300 mt-0.5">
{truncateText(conversationDraft.aiResponse, 200)}
</p>
</div>
</div>
<p className="text-xs text-gray-500 italic">
{locale === 'fr'
? 'Partagé depuis une conversation CanadaGPT'
: 'Shared from CanadaGPT conversation'}
</p>
</div>
{/* Fork info */}
<div className="px-3 py-2 bg-gray-900/30 border-t border-gray-700">
<div className="flex items-center gap-2 text-xs text-gray-400">
<GitFork className="w-3.5 h-3.5" />
<span>
{locale === 'fr'
? 'Les autres peuvent forker cette conversation pour continuer avec le contexte'
: 'Others can fork this conversation to continue with context'}
</span>
</div>
</div>
</div>
)}
{/* Textarea */}
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={handleContentChange}
onFocus={() => setIsFocused(true)}
placeholder={
placeholder ||
(locale === 'fr'
? 'Partagez votre avis...'
: 'Share your thoughts...')
}
className={`
w-full px-4 py-3
bg-slate-800 dark:bg-slate-800 border rounded-lg
text-white placeholder:text-gray-400
resize-none overflow-hidden
focus:outline-none focus:ring-2 focus:ring-accent-red/50
${isOverLimit ? 'border-red-500' : 'border-slate-600'}
${isFocused ? 'min-h-[100px]' : 'min-h-[44px]'}
transition-all duration-200
`}
rows={1}
disabled={isSubmitting}
/>
{/* Character count (show when focused or near limit) */}
{(isFocused || charCount > MAX_LENGTH * 0.8) && (
<div
className={`absolute bottom-2 right-2 text-xs ${
isOverLimit
? 'text-red-500 font-semibold'
: charCount > MAX_LENGTH * 0.9
? 'text-orange-500'
: 'text-text-tertiary'
}`}
>
{charCount.toLocaleString()} / {MAX_LENGTH.toLocaleString()}
</div>
)}
</div>
{/* Mention Autocomplete */}
<MentionAutocomplete
query={autocompleteQuery}
isVisible={showAutocomplete}
position={autocompletePosition}
onSelect={handleMentionSelect}
onCancel={handleAutocompleteCancel}
locale={locale}
maxSuggestions={6}
/>
{/* Error message */}
{error && (
<div className="text-sm text-red-500 flex items-center gap-1">
<span>⚠️</span>
<span>{error}</span>
</div>
)}
{/* Action buttons (show when focused or has content) */}
{(isFocused || content.length > 0) && (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
type="submit"
disabled={!isValid || isSubmitting}
className={`
px-4 py-2 text-sm font-medium rounded-lg
transition-colors
${isValid && !isSubmitting
? 'bg-accent-red hover:bg-red-600 text-white'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}
`}
>
{isSubmitting
? (locale === 'fr' ? 'Publication...' : 'Posting...')
: (locale === 'fr' ? 'Publier' : 'Post')}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white hover:bg-accent-red rounded-lg transition-colors"
>
{locale === 'fr' ? 'Annuler' : 'Cancel'}
</button>
</div>
</div>
)}
</form>
);
}
export default InlineCommentForm;