sojorn/_legacy/supabase/functions/_shared/r2_signer.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

109 lines
3.4 KiB
TypeScript

import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.gosojorn.com").trim();
const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.gosojorn.com").trim();
const DEFAULT_BUCKET_NAME = "sojorn-media";
const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim();
function normalizeKey(key: string): string {
let normalized = key.replace(/^\/+/, "");
if (RESOLVED_BUCKET && normalized.startsWith(`${RESOLVED_BUCKET}/`)) {
normalized = normalized.slice(RESOLVED_BUCKET.length + 1);
}
return normalized;
}
function extractObjectKey(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Missing file key");
}
try {
const url = new URL(trimmed);
const key = decodeURIComponent(url.pathname);
return normalizeKey(key);
} catch {
return normalizeKey(trimmed);
}
}
export function transformLegacyMediaUrl(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
// Handle legacy media.gosojorn.com URLs
if (url.hostname === 'media.gosojorn.com') {
const key = decodeURIComponent(url.pathname);
return key;
}
return null;
} catch {
return null;
}
}
// Deprecated: no-op signer retained for compatibility
export async function signR2Url(fileKey: string, expiresIn: number = 3600): Promise<string> {
return await trySignR2Url(fileKey, undefined, expiresIn) ?? fileKey;
}
export async function trySignR2Url(fileKey: string, bucket?: string, expiresIn: number = 3600): Promise<string | null> {
try {
const key = normalizeKey(extractObjectKey(fileKey));
// Check if we have credentials to sign. If not, fallback to public URL.
const ACCOUNT_ID = Deno.env.get('R2_ACCOUNT_ID');
const ACCESS_KEY = Deno.env.get('R2_ACCESS_KEY');
const SECRET_KEY = Deno.env.get('R2_SECRET_KEY');
const isVideo = key.toLowerCase().endsWith('.mp4') ||
key.toLowerCase().endsWith('.mov') ||
key.toLowerCase().endsWith('.webm') ||
bucket === 'sojorn-videos';
if (!ACCOUNT_ID || !ACCESS_KEY || !SECRET_KEY) {
console.warn("Missing R2 credentials for signing. Falling back to public domain.");
const domain = isVideo ? CUSTOM_VIDEO_DOMAIN : CUSTOM_MEDIA_DOMAIN;
if (domain && domain.startsWith("http")) {
return `${domain.replace(/\/+$/, "")}/${key}`;
}
return fileKey;
}
const r2 = new AwsClient({
accessKeyId: ACCESS_KEY,
secretAccessKey: SECRET_KEY,
region: 'auto',
service: 's3',
});
const targetBucket = bucket || (isVideo ? 'sojorn-videos' : 'sojorn-media');
// We sign against the actual R2 endpoint to ensure auth works,
// but the SignedMediaImage can handle redirect/proxying if needed.
const url = new URL(`https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${targetBucket}/${key}`);
// Add expiration
url.searchParams.set('X-Amz-Expires', expiresIn.toString());
const signedRequest = await r2.sign(url, {
method: "GET",
aws: { signQuery: true, allHeaders: false },
});
return signedRequest.url;
} catch (error) {
console.error("R2 signing failed", {
fileKey,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}