**Major Features Added:** - **Inline Reply System**: Replace compose screen with inline reply boxes - **Thread Navigation**: Parent/child navigation with jump functionality - **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy - **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions **Frontend Changes:** - **ThreadedCommentWidget**: Complete rewrite with animations and navigation - **ThreadNode Model**: Added parent references and descendant counting - **ThreadedConversationScreen**: Integrated navigation handlers - **PostDetailScreen**: Replaced with threaded conversation view - **ComposeScreen**: Added reply indicators and context - **PostActions**: Fixed visibility checks for chain buttons **Backend Changes:** - **API Route**: Added /posts/:id/thread endpoint - **Post Repository**: Include allow_chain and visibility fields in feed - **Thread Handler**: Support for fetching post chains **UI/UX Improvements:** - **Reply Context**: Clear indication when replying to specific posts - **Character Counting**: 500 character limit with live counter - **Visual Hierarchy**: Depth-based indentation and styling - **Smooth Animations**: SizeTransition, FadeTransition, hover states - **Chain Navigation**: Parent/child buttons with visual feedback **Technical Enhancements:** - **Animation Controllers**: Proper lifecycle management - **State Management**: Clean separation of concerns - **Navigation Callbacks**: Reusable navigation system - **Error Handling**: Graceful fallbacks and user feedback This creates a Reddit-style threaded conversation experience with smooth animations, inline replies, and intuitive navigation between posts in a chain.
319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
/// <reference types="https://deno.land/x/deno@v1.28.0/cli/dts/lib.deno.ts" />
|
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
|
|
|
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
|
|
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
};
|
|
|
|
// ============================================================================
|
|
// TONE CHECK (inline version to avoid external call)
|
|
// ============================================================================
|
|
|
|
interface ToneResult {
|
|
tone: 'positive' | 'neutral' | 'mixed' | 'negative' | 'hostile' | 'hate';
|
|
cis: number;
|
|
flags: string[];
|
|
reason: string;
|
|
}
|
|
|
|
function basicModeration(text: string): ToneResult {
|
|
const lowerText = text.toLowerCase();
|
|
const flags: string[] = [];
|
|
|
|
// Slur patterns
|
|
const slurPatterns = [/\bn+[i1]+g+[aegr]+/i, /\bf+[a4]+g+[s$o0]+t/i, /\br+[e3]+t+[a4]+r+d/i];
|
|
for (const pattern of slurPatterns) {
|
|
if (pattern.test(text)) {
|
|
return { tone: 'hate', cis: 0.0, flags: ['hate-speech'], reason: 'Hate speech detected.' };
|
|
}
|
|
}
|
|
|
|
// Attack patterns
|
|
const attackPatterns = [/\b(fuck|screw|damn)\s+(you|u|your|ur)/i, /\b(kill|hurt|attack)\s+(you|yourself)/i];
|
|
for (const pattern of attackPatterns) {
|
|
if (pattern.test(text)) {
|
|
return { tone: 'hostile', cis: 0.2, flags: ['hostile'], reason: 'Personal attack detected.' };
|
|
}
|
|
}
|
|
|
|
// Positive indicators
|
|
const positiveWords = ['thank', 'appreciate', 'love', 'support', 'grateful'];
|
|
if (positiveWords.some(word => lowerText.includes(word))) {
|
|
return { tone: 'positive', cis: 1.0, flags: [], reason: 'Positive content' };
|
|
}
|
|
|
|
return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' };
|
|
}
|
|
|
|
async function checkTone(text: string): Promise<ToneResult> {
|
|
const openAiKey = Deno.env.get('OPEN_AI');
|
|
|
|
if (openAiKey) {
|
|
try {
|
|
const response = await fetch('https://api.openai.com/v1/moderations', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${openAiKey}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ input: text, model: 'text-moderation-latest' }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const results = data.results?.[0];
|
|
if (results) {
|
|
if (results.flagged) {
|
|
if (results.categories?.hate) return { tone: 'hate', cis: 0.0, flags: ['hate'], reason: 'Hate speech detected.' };
|
|
if (results.categories?.harassment) return { tone: 'hostile', cis: 0.2, flags: ['harassment'], reason: 'Harassment detected.' };
|
|
return { tone: 'mixed', cis: 0.5, flags: ['flagged'], reason: 'Content flagged.' };
|
|
}
|
|
// Not flagged - return neutral with CIS based on scores
|
|
const maxScore = Math.max(results.category_scores?.harassment || 0, results.category_scores?.hate || 0);
|
|
if (maxScore > 0.5) return { tone: 'mixed', cis: 0.6, flags: [], reason: 'Content approved (caution)' };
|
|
return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('OpenAI moderation error:', error);
|
|
}
|
|
}
|
|
|
|
return basicModeration(text);
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN HANDLER
|
|
// ============================================================================
|
|
|
|
interface RequestBody {
|
|
action: 'edit' | 'delete' | 'update_privacy' | 'bulk_update_privacy' | 'pin' | 'unpin';
|
|
post_id?: string;
|
|
content?: string; // For edit action
|
|
visibility?: 'public' | 'followers' | 'private';
|
|
}
|
|
|
|
serve(async (req: Request) => {
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response('ok', { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST OPTIONS' } });
|
|
}
|
|
|
|
try {
|
|
const authHeader = req.headers.get('Authorization');
|
|
if (!authHeader) {
|
|
return new Response(JSON.stringify({ error: 'Missing authorization' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
const supabase = createSupabaseClient(authHeader);
|
|
const serviceClient = createServiceClient();
|
|
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
if (authError || !user) {
|
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
const { action, post_id, content, visibility } = await req.json() as RequestBody;
|
|
|
|
if (!action) {
|
|
return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
const allowedVisibilities = ['public', 'followers', 'private'];
|
|
if ((action === 'update_privacy' || action === 'bulk_update_privacy') && (!visibility || !allowedVisibilities.includes(visibility))) {
|
|
return new Response(JSON.stringify({ error: 'Invalid visibility' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (action === 'bulk_update_privacy') {
|
|
const { error: bulkError } = await serviceClient
|
|
.from('posts')
|
|
.update({ visibility })
|
|
.eq('author_id', user.id);
|
|
|
|
if (bulkError) {
|
|
console.error('Bulk privacy update error:', bulkError);
|
|
return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (!post_id) {
|
|
return new Response(JSON.stringify({ error: 'Missing post_id' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// Fetch the post
|
|
const { data: post, error: postError } = await serviceClient
|
|
.from('posts')
|
|
.select('id, author_id, body, tone_label, cis_score, created_at, is_edited')
|
|
.eq('id', post_id)
|
|
.single();
|
|
|
|
if (postError || !post) {
|
|
return new Response(JSON.stringify({ error: 'Post not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// Verify ownership
|
|
if (post.author_id !== user.id) {
|
|
return new Response(JSON.stringify({ error: 'You can only edit or delete your own posts' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// ========================================================================
|
|
// ACTION: PIN
|
|
// ========================================================================
|
|
if (action === 'pin') {
|
|
const pinnedAt = new Date().toISOString();
|
|
|
|
const { error: clearError } = await serviceClient
|
|
.from('posts')
|
|
.update({ pinned_at: null })
|
|
.eq('author_id', user.id);
|
|
|
|
if (clearError) {
|
|
console.error('Clear pinned post error:', clearError);
|
|
return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
const { error: pinError } = await serviceClient
|
|
.from('posts')
|
|
.update({ pinned_at: pinnedAt })
|
|
.eq('id', post_id);
|
|
|
|
if (pinError) {
|
|
console.error('Pin post error:', pinError);
|
|
return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, message: 'Post pinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// ========================================================================
|
|
// ACTION: UNPIN
|
|
// ========================================================================
|
|
if (action === 'unpin') {
|
|
const { error: unpinError } = await serviceClient
|
|
.from('posts')
|
|
.update({ pinned_at: null })
|
|
.eq('id', post_id);
|
|
|
|
if (unpinError) {
|
|
console.error('Unpin post error:', unpinError);
|
|
return new Response(JSON.stringify({ error: 'Failed to unpin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, message: 'Post unpinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// ========================================================================
|
|
// ACTION: UPDATE PRIVACY
|
|
// ========================================================================
|
|
if (action === 'update_privacy') {
|
|
const { error: privacyError } = await serviceClient
|
|
.from('posts')
|
|
.update({ visibility })
|
|
.eq('id', post_id);
|
|
|
|
if (privacyError) {
|
|
console.error('Privacy update error:', privacyError);
|
|
return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// ========================================================================
|
|
// ACTION: DELETE (Hard Delete)
|
|
// ========================================================================
|
|
if (action === 'delete') {
|
|
const { error: deleteError } = await serviceClient
|
|
.from('posts')
|
|
.delete()
|
|
.eq('id', post_id);
|
|
|
|
if (deleteError) {
|
|
console.error('Delete error:', deleteError);
|
|
return new Response(JSON.stringify({ error: 'Failed to delete post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, message: 'Post deleted successfully' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// ========================================================================
|
|
// ACTION: EDIT
|
|
// ========================================================================
|
|
if (action === 'edit') {
|
|
if (!content || content.trim().length === 0) {
|
|
return new Response(JSON.stringify({ error: 'Content is required for edits' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// 1. Time Check: 2-minute window
|
|
const createdAt = new Date(post.created_at);
|
|
const now = new Date();
|
|
const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000);
|
|
|
|
if (createdAt < twoMinutesAgo) {
|
|
return new Response(JSON.stringify({ error: 'Edit window expired. Posts can only be edited within 2 minutes of creation.' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// 2. Check if already edited (one edit only)
|
|
if (post.is_edited) {
|
|
return new Response(JSON.stringify({ error: 'Post has already been edited' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// 3. Moderation Check: Run new content through tone check
|
|
const moderation = await checkTone(content);
|
|
|
|
if (moderation.tone === 'hate' || moderation.tone === 'hostile') {
|
|
return new Response(JSON.stringify({
|
|
error: 'Edit rejected: Content does not meet community guidelines.',
|
|
details: moderation.reason
|
|
}), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// 4. Archive: Save current content to post_versions
|
|
const { error: archiveError } = await serviceClient
|
|
.from('post_versions')
|
|
.insert({
|
|
post_id: post_id,
|
|
content: post.body,
|
|
tone_label: post.tone_label,
|
|
cis_score: post.cis_score,
|
|
});
|
|
|
|
if (archiveError) {
|
|
console.error('Archive error:', archiveError);
|
|
return new Response(JSON.stringify({ error: 'Failed to save edit history' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// 5. Update: Apply new content
|
|
const { error: updateError } = await serviceClient
|
|
.from('posts')
|
|
.update({
|
|
body: content,
|
|
tone_label: moderation.tone,
|
|
cis_score: moderation.cis,
|
|
is_edited: true,
|
|
edited_at: now.toISOString(),
|
|
})
|
|
.eq('id', post_id);
|
|
|
|
if (updateError) {
|
|
console.error('Update error:', updateError);
|
|
return new Response(JSON.stringify({ error: 'Failed to update post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
message: 'Post updated successfully',
|
|
moderation: { tone: moderation.tone, cis: moderation.cis }
|
|
}), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Invalid action' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
|
|
} catch (error) {
|
|
console.error('Unexpected error:', error);
|
|
return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
|
}
|
|
});
|