sojorn/_legacy/supabase/functions/search/index.ts
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**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.
2026-01-30 07:40:19 -06:00

288 lines
9.9 KiB
TypeScript

import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts";
import { trySignR2Url } from "../_shared/r2_signer.ts";
interface Profile {
id: string;
handle: string;
display_name: string;
avatar_url: string | null;
harmony_tier: string | null;
}
interface SearchUser {
id: string;
username: string;
display_name: string;
avatar_url: string | null;
harmony_tier: string;
}
interface SearchTag {
tag: string;
count: number;
}
interface SearchPost {
id: string;
body: string;
author_id: string;
author_handle: string;
author_display_name: string;
created_at: string;
image_url: string | null;
visibility?: string;
}
function extractHashtags(text: string): string[] {
const matches = text.match(/#\w+/g) || [];
return [...new Set(matches.map((t) => t.replace("#", "").toLowerCase()))].filter((t) => t.length > 0);
}
function stripHashtags(text: string): string {
return text.replace(/#\w+/g, " ").replace(/\s+/g, " ").trim();
}
serve(async (req: Request) => {
// 1. Handle CORS Preflight
if (req.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
},
});
}
try {
// 2. Auth & Input Parsing
const authHeader = req.headers.get("Authorization");
if (!authHeader) throw new Error("Missing authorization header");
let query: string | null = null;
if (req.method === "POST") {
try {
const body = await req.json();
query = body.query;
} catch { /* ignore parsing error */ }
}
if (!query) {
const url = new URL(req.url);
query = url.searchParams.get("query");
}
// Return empty if no query
if (!query || query.trim().length === 0) {
return new Response(
JSON.stringify({ users: [], tags: [], posts: [] }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
const rawQuery = query.trim();
const isHashtagSearch = rawQuery.startsWith("#");
const cleanTag = isHashtagSearch ? rawQuery.replace("#", "").toLowerCase() : "";
const hashtagFilters = extractHashtags(rawQuery);
const ftsQuery = stripHashtags(rawQuery).replace(/[|&!]/g, " ").trim();
const hasFts = ftsQuery.length > 0;
const safeQuery = rawQuery.toLowerCase().replace(/[,()]/g, "");
console.log("Search query:", rawQuery, "isHashtagSearch:", isHashtagSearch, "cleanTag:", cleanTag);
const supabase = createSupabaseClient(authHeader);
const serviceClient = createServiceClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) throw new Error("Unauthorized");
// 3. Prepare Exclusion Lists (Blocked Users)
const { data: blockedUsers } = await serviceClient
.from("blocks")
.select("blocked_id")
.eq("blocker_id", user.id);
const blockedIds = (blockedUsers?.map((b) => b.blocked_id) || []);
const excludeIds = [...blockedIds, user.id]; // Exclude self from user search
const postExcludeIds = blockedIds; // Allow own posts in search
// 4. Parallel Execution (Fastest Approach)
// Users + tags first so tag matches can inform post search.
const [usersResult, tagsResult] = await Promise.all([
// A. Search Users
(async () => {
let dbQuery = serviceClient
.from("profiles")
.select("id, handle, display_name, avatar_url")
.or(`handle.ilike.%${safeQuery}%,display_name.ilike.%${safeQuery}%`)
.limit(5);
if (excludeIds.length > 0) {
dbQuery = dbQuery.not("id", "in", `(${excludeIds.join(",")})`);
}
return await dbQuery;
})(),
// B. Search Tags (Using the View for performance)
(async () => {
// NOTE: Ensure you have run the SQL to create 'view_searchable_tags'
// If view missing, this returns error, handle gracefully
return await serviceClient
.from("view_searchable_tags")
.select("tag, count")
.ilike("tag", `%${isHashtagSearch ? cleanTag : safeQuery}%`)
.order("count", { ascending: false })
.limit(5);
})(),
]);
const matchedTags = (tagsResult.data || [])
.map((t: any) => String(t.tag).toLowerCase())
.filter((t: string) => t.length > 0);
const tagCandidates = matchedTags.length > 0
? matchedTags
: (isHashtagSearch && cleanTag.length > 0 ? [cleanTag] : []);
// C. Search Posts (tag-first for hashtag queries; hybrid for others)
const postsResult = await (async () => {
const postsMap = new Map<string, any>();
if (isHashtagSearch) {
if (cleanTag.length > 0) {
let exactTagQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.contains("tags", [cleanTag])
.order("created_at", { ascending: false })
.limit(20);
if (excludeIds.length > 0) {
exactTagQuery = exactTagQuery.not("author_id", "in", `(${excludeIds.join(",")})`);
}
const exactTagResult = await exactTagQuery;
(exactTagResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
if (tagCandidates.length > 0) {
let tagQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.overlaps("tags", tagCandidates)
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
tagQuery = tagQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
const tagResult = await tagQuery;
(tagResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
return { data: Array.from(postsMap.values()).slice(0, 20), error: null };
}
if (hasFts) {
let ftsDbQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
ftsDbQuery = ftsDbQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
ftsDbQuery = ftsDbQuery.textSearch("fts", ftsQuery, {
type: "websearch",
config: "english",
});
const ftsResult = await ftsDbQuery;
(ftsResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
if (tagCandidates.length > 0) {
let tagOverlapQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.overlaps("tags", tagCandidates)
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
const tagOverlapResult = await tagOverlapQuery;
(tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
if (hashtagFilters.length > 0) {
let tagOverlapQuery = serviceClient
.from("posts")
.select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)")
.overlaps("tags", hashtagFilters)
.order("created_at", { ascending: false })
.limit(20);
if (postExcludeIds.length > 0) {
tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`);
}
const tagOverlapResult = await tagOverlapQuery;
(tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p));
}
return { data: Array.from(postsMap.values()).slice(0, 20), error: null };
})();
// 5. Process Users (Get Harmony Tiers)
const profiles = usersResult.data || [];
let users: SearchUser[] = [];
if (profiles.length > 0) {
const { data: trustStates } = await serviceClient
.from("trust_state")
.select("user_id, tier")
.in("user_id", profiles.map(p => p.id));
const trustMap = new Map(trustStates?.map(t => [t.user_id, t.tier]) || []);
users = profiles.map((p: any) => ({
id: p.id,
username: p.handle,
display_name: p.display_name || p.handle,
avatar_url: p.avatar_url,
harmony_tier: trustMap.get(p.id) || "new",
}));
}
// 6. Process Tags
const tags: SearchTag[] = (tagsResult.data || []).map((t: any) => ({
tag: t.tag,
count: t.count
}));
// 7. Process Posts
const searchPosts: SearchPost[] = await Promise.all(
(postsResult.data || []).map(async (p: any) => ({
id: p.id,
body: p.body,
author_id: p.author_id,
author_handle: p.profiles?.handle || "unknown",
author_display_name: p.profiles?.display_name || "Unknown User",
created_at: p.created_at,
image_url: p.image_url ? await trySignR2Url(p.image_url) : null,
visibility: p.visibility,
}))
);
// 8. Return Result
return new Response(JSON.stringify({ users, tags, posts: searchPosts }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error: any) {
console.error("Search error:", error);
return new Response(
JSON.stringify({ error: error.message || "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});